Time goes by at one second at a time, and that is the last simple thing about it. A second is a count of caesium atoms, a day is a turn of the earth, a month is a turn of the moon or the decision of a committee, a year is a turn of the sun, and none of them fits inside the next. So we keep arrangements instead: zones that shift an hour twice a year, a leap day most years in four, a leap second when the earth runs slow, and a dozen calendars that cut the same days into different months. A date library is a place to keep those arrangements so that a program can ask what time it is and give an answer two people in two places will both recognise. Most of them manage the common case and make you pay for it on every call.
Time::Keeper is a new CPAN distribution for dates and times. A value is an instant and the zone it is read in, and that is the whole of it: no state to mutate, no locale object, no copy of the zone rules. It covers the ground Time::Piece and DateTime cover between them, and a method that would change a value hands back a new one and leaves the old one as it was.
use Time::Keeper;
my $t = Time::Keeper->new(year => 2026, month => 10, day => 15, hour => 9, zone => 'Europe/London');
print "$t\n"; # 2026-10-15T09:00:00+01:00
print $t->in_zone('Asia/Tokyo'), "\n"; # 2026-10-15T17:00:00+09:00
print $t->plus(months => 1), "\n"; # 2026-11-15T09:00:00+00:00
I wrote it because every program I have that handles time pays a price for it. DateTime takes 54 ms to load and makes a 1.5 KB object for every value. Time::Piece is lighter but knows nothing of zones beyond the one the process runs in, and nothing of locales at all. Neither knows a leap second. So this post starts with the numbers, then walks through the ordinary work, then ends with what Time::Keeper does that nothing else on CPAN does.
The benchmark
Measured on an Apple M5 with perl 5.42, Time::Piece 1.36 and DateTime 1.66, by the bench/ scripts in the distribution. A rate is calls a second over half a second of CPU, the best of five, and each mark was set before any number was seen.
| Time::Keeper | Time::Piece | DateTime | |
|---|---|---|---|
use, in a fresh process |
1.9 ms | 3.9 ms | 53.6 ms |
now |
12.98M/s | 980k/s | 171k/s |
| epoch to year, month and day | 5.29M/s | 827k/s | 148k/s |
| new from fields in America/New_York | 2.66M/s | 73k/s | |
strftime('%Y-%m-%d %H:%M:%S') |
9.09M/s | 1.04M/s | 362k/s |
| the same instant in another zone | 7.86M/s | 148k/s | |
| plus one month | 11.1M/s | 894k/s | 37k/s |
| the difference of two values | 5.89M/s | 1.89M/s | 135k/s |
| parse an ISO 8601 string | 9.27M/s | 1.16M/s | |
format('EEEE d MMMM y') in French |
9.62M/s | 68k/s | |
| round to the nearest 15 minutes | 5.00M/s | 114k/s | |
| the next Monday | 8.66M/s | 10k/s | |
| bytes a value costs | 170 | 606 | 1,557 |
| installed, with zones and locales | 10.7 MB | 20.5 MB |
Adding a month is three hundred times faster than DateTime. Finding the next Monday is eight hundred times faster, because DateTime has no adjuster and the usual loop makes seven values to find it.
The reason is not cleverness. A value is one 72-byte C struct: the instant as seconds and nanoseconds, the offset, a zone id, a locale id, and a cache of the civil fields filled on the first read. Making one allocates nothing but the scalar it lives in. The zone rules for all 344 zones are compiled into Time::Keeper::Zone from the IANA database, and a program that works in UTC never loads them. The names and formats of 1,121 locales are compiled into one archive, English into the module itself, and a locale's section is read the first time that locale is asked for. There is no object for a zone and no object for a locale.
A quick tour
Making a value
my $now = Time::Keeper->now; # UTC
my $there = Time::Keeper->now(zone => 'Asia/Tokyo');
my $here = Time::Keeper->now(zone => 'local');
my $t = Time::Keeper->new(year => 2026, month => 10, day => 15, hour => 9, zone => 'Europe/London');
my $e = Time::Keeper->from_epoch('1760000000.123456789', zone => '+05:30');
my $p = Time::Keeper->parse('2026-10-15T09:00:00+01:00[Europe/London]');
A zone is an IANA name, UTC, local, floating for a wall clock with no zone, or an offset. The epoch takes a string, so nine decimal places survive: $e->nanosecond is 123456789, not what a double made of it.
The accessors are the ones you expect, year through nanosecond, day_of_week, day_of_year, week, quarter, days_in_month, is_leap_year, epoch, offset, zone_name, zone_abbr, is_dst, and a value prints as ISO 8601.
Zones, and the two things that go wrong in them
Twice a year a wall-clock time either did not happen or happened twice. Time::Keeper refuses to guess about the first and tells you which it chose for the second:
Time::Keeper->new(year => 2026, month => 3, day => 8, hour => 2, minute => 30, zone => 'America/New_York');
# dies: 2026-03-08 02:30:00 did not happen in America/New_York: the clocks went
# forward over it (gap => 'forward' or 'backward' takes it anyway)
my $t = Time::Keeper->new(year => 2026, month => 11, day => 1, hour => 1, minute => 30,
zone => 'America/New_York');
print "$t\n"; # 2026-11-01T01:30:00-04:00, the earlier of the two
print $t->is_ambiguous, "\n"; # 1
print $t->other_reading, "\n"; # 2026-11-01T01:30:00-05:00
print $t->next_transition, "\n"; # the next time the clocks change, as a value
overlap => 'later' takes the other reading at construction. DateTime takes the later one silently, which is why its default differs from Java's, Python's and Temporal's, and from this one.
Arithmetic that adds back
my $a = Time::Keeper->new(year => 2026, month => 1, day => 31, zone => 'UTC');
my $b = Time::Keeper->new(year => 2026, month => 3, day => 1, hour => 12, zone => 'UTC');
my $d = $b - $a; # P29DT12H
print $a + $d == $b ? "yes\n" : "no\n"; # yes, for every pair of values
$a->plus(months => 1); # 2026-03-03, Jan 31 wrapped past Feb
$a->plus(months => 1, end_of_month => 'limit'); # 2026-02-28
$a->until($b, largest => 'hours'); # PT708H
$a->until($b, largest => 'hours')->total('hours'); # 708
Months are added before days, as Java, Python and Temporal do it, and the difference is defined as the duration that adds back to exactly the second value. DateTime adds days before months, and in a run of 9,000 random pairs 137 of them did not round-trip there. Here every pair does, and the test suite proves it on 20,000 fresh pairs each run.
The rest of the arithmetic you would otherwise write by hand:
$t->end_of(to => 'month'); # 2026-10-31T23:59:59.999999999+00:00
$t->next(day_of_week => 1); # the Monday after, same time of day
$t->nth_in_month(day_of_week => 4, n => 3); # the third Thursday
$t->round(to => 'minute', increment => 15); # 09:37 becomes 09:30
$t->truncate(to => 'week'); # the Monday
$t->range($end, by => 'day'); # every day to $end, inclusive
Formatting, in any locale
Both pattern languages are there, strftime's and CLDR's, plus the locale's own four date and time formats and its skeletons:
$t->strftime('%A %d %B %Y %H:%M %Z'); # Thursday 15 October 2026 09:00 BST
$t->format('EEEE d MMMM y HH:mm zzzz'); # Thursday 15 October 2026 09:00 British Summer Time
$t->in_locale('fr')->format_date('full'); # jeudi 15 octobre 2026
$t->in_locale('ja')->format_datetime('long'); # 2026年10月15日 9:00:00 GMT+1
$t->in_locale('ar-EG')->format_date('medium'); # ١٥/١٠/٢٠٢٦, in the locale's digits
$t->in_locale('de')->format_skeleton('yMMMEd'); # Do., 15. Okt. 2026
$t->rfc2822; $t->http_date; $t->rfc9557; $t->iso8601(form => 'week');
Relative time and intervals come from the same data, so they are right in every locale rather than in English with a dictionary:
$t->format_relative(to => $now); # 3 hours ago
$t->in_locale('fr')->format_relative(to => $now); # il y a 3 heures
$t->minus(days => 1)->format_relative(to => $now, unit => 'day'); # yesterday
$t->format_interval($t->plus(days => 2), skeleton => 'yMMMd'); # Oct 15 – 17, 2026
Time::Keeper::Duration->new(hours => 1, minutes => 30)->format; # 1 hour, 30 minutes
Time::Keeper::Duration->new(hours => 1, minutes => 5, seconds => 9)->format(style => 'digital'); # 1:05:09
Parsing
parse reads ISO 8601 in all its forms, RFC 3339 and 9557, the mail and HTTP dates, and @epoch. strptime reads back everything strftime writes; parse_cldr reads back everything format writes, in the locale given; and each has a try_ form that gives undef instead of dying.
Time::Keeper->parse('Thu, 15 Oct 2026 09:00:00 +0100');
Time::Keeper->strptime('15/10/2026 09:00', '%d/%m/%Y %H:%M', zone => 'Europe/London');
Time::Keeper->parse_cldr('jeudi 15 octobre 2026', 'EEEE d MMMM y', locale => 'fr');
Time::Keeper->try_parse('yesterday'); # undef
Dates without times
A birthday is not a nanosecond at midnight. Four plain classes hold the parts on their own, each with the arithmetic that makes sense for it:
my $d = Time::Keeper::Date->new(year => 2026, month => 10, day => 15); # 2026-10-15
my $tm = Time::Keeper::Time->new(hour => 23, minute => 30);
$tm->plus(hours => 1); # 00:30:00, it wraps
Time::Keeper::YearMonth->new(year => 2026, month => 10)->last_day; # 2026-10-31
Time::Keeper::MonthDay->new(month => 2, day => 29)->in_year(2027, end_of_month => 'limit'); # 2027-02-28
$d->at($tm, zone => 'Europe/London'); # a Time::Keeper
$d->plus(hours => 3); # dies: a date has no clock
Living with the others
Values compare with <=> and ==, stringify, serialise through Storable, through FREEZE and THAW for Sereal and CBOR::XS, and through TO_JSON. from_object takes a DateTime, a Time::Piece or anything with utc_rd_values or an epoch, and DateTime's from_object takes a Time::Keeper:
Time::Keeper->from_object(object => $datetime);
DateTime->from_object(object => $t);
What the others do not do
Leap seconds. 23:59:60 UTC existed 27 times between 1972 and 2016, and a log line from one of those seconds is a real time. Time::Keeper accepts second => 60 at exactly those seconds, in any zone (it was 08:59:60 in Tokyo), and refuses it at any other. The difference of two values counts them. The list comes from the IERS bulletin, and leap_seconds_expire says how far it is good for. DateTime and Time::Piece cannot represent the second at all.
Nanoseconds and a billion years. Nine decimal places in and out, exact, and a calendar that runs from the year -999,999,999 to 999,999,999 without a special case.
Every locale. The 1,121 locale codes of CLDR 48, with their month and day names, date and time formats, skeletons, relative-time words, duration words, interval formats, list patterns, plural rules, first day of the week, weekend, and their own digits. DateTime::Locale has the names and formats; the rest, from "3 hours ago" to "Oct 15 – 17, 2026" to whether Friday is a weekend in Cairo, is not on CPAN in any locale but English.
Every calendar. Seventeen calendar ids, the thirteen arithmetic ones and the Chinese, Dangi and two astronomical Hijri calendars read from tables of what ICU computes, each with its names and formats in every locale:
my $h = $t->in_calendar('hebrew'); # the same instant, read in another calendar
$h->format('d MMMM y'); # 4 Heshvan 5787
$h->in_locale('he')->format_date('long'); # ד׳ בחשוון תשפ״ז
$t->in_calendar('japanese')->in_locale('ja')->format_date('long'); # 令和8年10月15日
$t->in_calendar('chinese')->in_locale('zh')->format_date('full'); # 2026丙午年九月初六星期四
$t->in_calendar('islamic-umalqura')->format('d MMMM y G'); # 4 Jumada I 1448 AH
Time::Keeper->new(year => 5787, month => 1, day => 1, calendar => 'hebrew', zone => 'Asia/Jerusalem');
$h->plus(years => 1); # 4 Heshvan 5788, Adar I stays Adar I where there is one
$h->rfc9557; # 2026-10-15T09:00:00+01:00[Europe/London][u-ca=hebrew]
Time::Keeper::Calendar->preferred('IL'); # gregory hebrew islamic islamic-civil islamic-tbla
Arithmetic, the round-trip guarantee, formatting and parsing all hold in every calendar, and a Hebrew date in Hebrew comes out in Hebrew letters because that is what CLDR says a Hebrew date in Hebrew should be.
The zone engine, exposed. Time::Keeper::Zone is its own distribution, with tzdata 2026e compiled in, and answers questions a value cannot: every transition of a zone between two instants, the standard offset, whether a zone has DST in a year, the zones of a country and the country of a zone, and the Windows name for a zone and back. A value can ask next_transition, previous_transition, is_ambiguous and other_reading.
A clock for tests. local $Time::Keeper::CLOCK = $fixed pins now and today for one block, with no global and no CORE::GLOBAL::time hack that XS code never sees.
A C ABI. Another extension's C can read the clock, find a zone, turn an instant into a wall clock in any zone or any calendar and back, format and parse, and make a Time::Keeper value or read one Perl made, through one installed header found with ExtUtils::Depends. Nothing is linked against, the table is versioned, and the two distributions upgrade independently.
One answer to a mistake. Every method either does the thing or dies with the reason: a date that does not exist, a time the clocks skipped, a pattern letter a date has no field for, a calendar year outside its table. Nothing is silently clamped, wrapped or guessed unless you asked for that by name.
Getting it
Time::Keeper 0.05 and Time::Keeper::Zone 0.04 are on CPAN. They need perl 5.10 or later and a C compiler.
cpanm Time::Keeper::Zone Time::Keeper
Top comments (0)