perl.gg / snippets

<!-- category: snippets -->

Self-Expiring Hash Entries via tie

2026-04-07

You write $cache{$key} = $value. Two minutes later, it is gone. Nobody deleted it. No cron job. No background thread. The hash entry simply expired, like milk left on the counter.
my %cache; tie %cache, 'ExpiringHash', ttl => 60; $cache{session_abc} = { user => 'dave', role => 'admin' }; # 59 seconds later... say $cache{session_abc}{user}; # dave # 61 seconds later... say $cache{session_abc}; # undef (gone)
Normal hash syntax. Normal hash behavior. But every entry has a time-to-live. When you fetch a key past its expiration, the hash pretends it was never there. No external modules. No event loops. Just Perl's tie mechanism doing what it does best: making ordinary syntax do extraordinary things.

Part 1: THE CONCEPT

A TTL cache is simple. Every value gets a timestamp. When you read a key, check the timestamp. If too much time has passed, the entry is dead. Return undef. Clean it up.
STORE "foo" => "bar" at time 1000 TTL = 60 seconds expires at 1060 FETCH "foo" at time 1030 => "bar" (alive, 30s left) FETCH "foo" at time 1059 => "bar" (alive, 1s left) FETCH "foo" at time 1061 => undef (expired, deleted)
The trick is making this transparent. You do not want to write $cache->get_if_not_expired('foo') everywhere. You want plain $cache{foo}. Perl's tie interface lets you intercept every hash operation and inject your expiry logic behind the scenes.

Part 2: THE TIEHASH INTERFACE

To tie a hash, you write a class that implements specific methods. Perl calls these methods automatically whenever someone uses the tied hash:
OPERATION METHOD CALLED ------------------- ---------------------- tie %hash, 'Class' TIEHASH (constructor) $hash{key} FETCH $hash{key} = val STORE delete $hash{key} DELETE exists $hash{key} EXISTS keys %hash FIRSTKEY / NEXTKEY %hash = () CLEAR untie %hash DESTROY
You do not call these methods yourself. Perl calls them for you. Your code just uses $hash{key} and the magic happens underneath.

Part 3: THE IMPLEMENTATION

Here is the complete module. It stores each value alongside its insertion timestamp, and checks expiry on every FETCH:
package ExpiringHash; use strict; use warnings; sub TIEHASH { my ($class, %opts) = @_; my $ttl = $opts{ttl} || 300; # default 5 minutes return bless { _data => {}, _ttl => $ttl, }, $class; } sub STORE { my ($self, $key, $value) = @_; $self->{_data}{$key} = { value => $value, timestamp => time(), }; } sub FETCH { my ($self, $key) = @_; return undef unless exists $self->{_data}{$key}; my $entry = $self->{_data}{$key}; if (time() - $entry->{timestamp} > $self->{_ttl}) { delete $self->{_data}{$key}; # expired, clean up return undef; } return $entry->{value}; } sub EXISTS { my ($self, $key) = @_; return 0 unless exists $self->{_data}{$key}; my $entry = $self->{_data}{$key}; if (time() - $entry->{timestamp} > $self->{_ttl}) { delete $self->{_data}{$key}; return 0; } return 1; } sub DELETE { my ($self, $key) = @_; delete $self->{_data}{$key}; } sub CLEAR { my ($self) = @_; $self->{_data} = {}; } sub FIRSTKEY { my ($self) = @_; $self->_purge_expired(); my @keys = keys %{ $self->{_data} }; $self->{_iter} = \@keys; return shift @{ $self->{_iter} }; } sub NEXTKEY { my ($self, $lastkey) = @_; return shift @{ $self->{_iter} }; } sub DESTROY { } sub _purge_expired { my ($self) = @_; my $now = time(); for my $key (keys %{ $self->{_data} }) { my $entry = $self->{_data}{$key}; if ($now - $entry->{timestamp} > $self->{_ttl}) { delete $self->{_data}{$key}; } } } 1;
That is the whole thing. About 80 lines. Every hash operation is intercepted, and expiry is checked on every read.

Part 4: USING IT

Drop the module in your project and tie away:
use strict; use warnings; use feature 'say'; # assuming ExpiringHash.pm is in your @INC use ExpiringHash; my %cache; tie %cache, 'ExpiringHash', ttl => 10; $cache{greeting} = "hello"; $cache{count} = 42; say $cache{greeting}; # hello say exists $cache{count}; # 1 say scalar keys %cache; # 2 sleep 11; say $cache{greeting} // "gone"; # gone say exists $cache{count}; # 0 say scalar keys %cache; # 0
From the outside, it looks and smells like a normal hash. The TTL logic is invisible. Code that receives %cache as a parameter does not need to know or care that entries expire. It just uses hash syntax like always.

Part 5: PER-KEY TTL

The basic version gives every key the same TTL. But what if you want different lifetimes for different entries? Easy. Change STORE to accept a TTL alongside the value:
sub STORE { my ($self, $key, $value) = @_; # if value is an arrayref [data, ttl], use custom TTL if (ref $value eq 'ARRAY' && @$value == 2) { $self->{_data}{$key} = { value => $value->[0], timestamp => time(), ttl => $value->[1], }; } else { $self->{_data}{$key} = { value => $value, timestamp => time(), ttl => $self->{_ttl}, }; } } sub FETCH { my ($self, $key) = @_; return undef unless exists $self->{_data}{$key}; my $entry = $self->{_data}{$key}; my $ttl = $entry->{ttl} // $self->{_ttl}; if (time() - $entry->{timestamp} > $ttl) { delete $self->{_data}{$key}; return undef; } return $entry->{value}; }
Now you can do:
$cache{short_lived} = ["temporary data", 5]; # 5 second TTL $cache{long_lived} = ["persistent data", 3600]; # 1 hour TTL $cache{normal} = "just a string"; # default TTL
The convention is a bit ugly. You lose the ability to store plain arrayrefs as values unless you wrap them. A cleaner approach would be a separate method on the tied object, but that breaks the "just use hash syntax" goal. Trade-offs.

Part 6: PRACTICAL USE: RATE LIMITING

Throttle API calls. Allow 5 requests per minute per IP:
use strict; use warnings; use feature 'say'; my %rate; tie %rate, 'ExpiringHash', ttl => 60; sub check_rate_limit { my ($ip) = @_; $rate{$ip} = 0 unless defined $rate{$ip}; $rate{$ip}++; if ($rate{$ip} > 5) { return 0; # rate limited } return 1; # allowed } # simulate requests for my $i (1 .. 8) { my $allowed = check_rate_limit('192.168.1.1'); say "Request $i: " . ($allowed ? "OK" : "BLOCKED"); }
Request 1: OK Request 2: OK Request 3: OK Request 4: OK Request 5: OK Request 6: BLOCKED Request 7: BLOCKED Request 8: BLOCKED
Wait 60 seconds, and the counter expires. The IP gets a fresh allowance. No cleanup code. No timers. The hash just forgets.

Part 7: PRACTICAL USE: SESSION STORAGE

Web session data that auto-expires:
my %sessions; tie %sessions, 'ExpiringHash', ttl => 1800; # 30 minutes sub create_session { my ($user) = @_; my $sid = generate_session_id(); $sessions{$sid} = { user => $user, created => time(), last_seen => time(), }; return $sid; } sub get_session { my ($sid) = @_; return $sessions{$sid}; # undef if expired } sub touch_session { my ($sid) = @_; if (my $data = $sessions{$sid}) { $data->{last_seen} = time(); $sessions{$sid} = $data; # re-STORE resets the clock } }
The touch_session function is the clever bit. Re-storing the value resets the timestamp, so active sessions keep living. Idle sessions die after 30 minutes of silence. Exactly how session timeouts should work.

Part 8: PRACTICAL USE: DNS CACHE

Cache DNS lookups with the same TTL the DNS server gave you:
use Socket; my %dns; tie %dns, 'ExpiringHash', ttl => 300; # 5 minute default sub resolve { my ($hostname) = @_; if (exists $dns{$hostname}) { return $dns{$hostname}; # cached } my $packed = gethostbyname($hostname); return undef unless $packed; my $ip = inet_ntoa($packed); $dns{$hostname} = $ip; # cache it return $ip; } # first call hits DNS say resolve('perl.org'); # second call uses cache (fast) say resolve('perl.org'); # after 5 minutes, cache expires, next call hits DNS again
Simple. No CPAN dependencies. No background refresh threads. The cache is self-managing because the hash is self-expiring.

Part 9: COMPARISON TO REAL CACHE MODULES

For production systems, you should probably know about CHI and Cache::Cache:
FEATURE ExpiringHash CHI Cache::Cache ------------------ ------------ ----------- ------------ Install required no (DIY) yes (CPAN) yes (CPAN) Backend options memory only many many Per-key TTL with hack native native Namespaces no yes yes Serialization no yes yes Max size / eviction no yes yes Thread safe no configurable no Tested in prod by you by thousands by thousands
The self-expiring hash is perfect for small scripts, prototyping, and situations where you want zero dependencies. For anything with serious traffic, persistent storage, or multi-process access, reach for CHI.

But here is the thing. Most of the time, you are writing a script that needs to cache 50 DNS lookups or throttle a few API calls. You do not need a full caching framework for that. An 80-line tied hash does the job.

Part 10: THE TIE PHILOSOPHY

The beauty of tie is not that it lets you build caches. It is that it lets you build caches that look like hashes.

Any code that works with a hash works with your tied hash. You can pass %cache to a function that has no idea entries expire. You can dump it with Data::Dumper. You can grep its keys. You can slice it.

# all of these work on the tied hash my @active = grep { exists $cache{$_} } @keys; my @vals = @cache{@some_keys}; my $count = scalar keys %cache;
The tie interface is one of Perl's most underrated features. It turns any variable into an API without changing the syntax. Your hash is not just a hash. It is a hash with opinions about time.
.--. |o_o | "Your keys have an expiration date. |:_/ | Check the label." // \ \ (| | ) /'\_ _/`\ \___)=(___/
A hash that forgets is surprisingly useful. Rate limiters, session stores, DNS caches, circuit breakers, dedup windows. All of them boil down to "remember this for a while, then stop."

Perl does not have built-in TTL hashes. But it has tie, which means you can build one in 80 lines and never think about it again. The hash remembers so you do not have to. And then it forgets, which is even better.

perl.gg