perl.gg / snippets

Self-Expiring Hash Entries via tie

2026-04-07

This page shows how to make a Perl hash automatically remove entries after a set time, using only the built-in tie mechanism. You get normal $hash{key} syntax with invisible expiration built in.
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)
The hash still behaves like a regular hash, but every stored value carries a timestamp. When you read a key past its expiration window, the hash returns undef as if the entry never existed. You do not need external modules or background workers. The tie mechanism handles the expiration logic transparently.

Part 1: THE CONCEPT

A TTL cache stores a timestamp alongside each value. When you read a key, the cache compares the current time to that stored timestamp. If the difference exceeds the allowed window, the entry is treated as dead and removed.
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 goal here is invisibility. You do not want to call $cache->get_if_not_expired('foo') on every read. You want plain $cache{foo} to work. The tie interface intercepts every hash operation and runs the expiry check behind the scenes.

Part 2: THE TIEHASH INTERFACE

To tie a hash, you write a class that implements a specific set of methods. Perl invokes those methods automatically whenever your code touches 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 never call these methods directly. Perl routes every $hash{key} access through them. Your application code just uses normal hash syntax while the tied class handles the bookkeeping.

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 covers the full module. It runs about 80 lines and intercepts every standard hash operation. The expiry check runs on every read, and stale entries get cleaned up on the spot.

Part 4: USING IT

Drop the module into your project and tie your hash:
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
The cache looks and behaves like a normal hash from the outside. The TTL logic stays hidden inside the tied class. Any function that accepts a plain hash parameter will work without knowing that entries expire.

Part 5: PER-KEY TTL

The basic version assigns one TTL to every key. If you need different lifetimes for different entries, you can extend STORE to accept a per-key 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 assign different lifetimes per entry:
$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 slightly awkward because you lose the ability to store plain array references as values without wrapping them. A cleaner path would be a separate method on the tied object, but that breaks the "just use hash syntax" goal. Trade-offs are part of the design.

Part 6: PRACTICAL USE: RATE LIMITING

You can throttle API calls by tracking request counts per IP address:
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 address gets a fresh allowance. You do not need cleanup code or timers. The hash simply forgets the old count.

Part 7: PRACTICAL USE: SESSION STORAGE

Web session data that auto-expires works the same way:
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 useful part. Re-storing the value resets the timestamp, so active sessions keep living. Idle sessions die after 30 minutes of silence. That matches how session timeouts should behave.

Part 8: PRACTICAL USE: DNS CACHE

You can cache DNS lookups with the same TTL the DNS server originally provided:
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
The cache manages itself without CPAN dependencies or background refresh threads. The hash stays self-expiring, so stale entries disappear on their own.

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 fits small scripts, quick prototypes, and situations where you want zero dependencies. For anything handling serious traffic, persistent storage, or multi-process access, reach for CHI.

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

Part 10: THE TIE PHILOSOPHY

The value of tie is not that it builds caches. It is that it builds caches that look like hashes.

Any code that works with a plain hash will work 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 stops being just a hash and becomes 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, and dedup windows all boil down to "remember this for a while, then stop."

Perl does not ship with built-in TTL hashes. The tie mechanism 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