Overview
A single Perl hash maps one flat key to one scalar value, which is not quite enough to model a contact — a name needs to map to several fields at once: a phone number, an email, maybe an address. A hash of hashes solves this by making each value itself a reference to another hash, so `%contacts` maps a name to a reference like `{ phone => "...", email => "..." }`, and that inner hash is reached through an arrow, `$contacts{$name}{phone}`.
By the end of this tutorial you will have a console contact manager built around one hash of hashes, `%contacts`, with add, search, save-to-file, and load-from-file operations. Along the way you will work directly with Perl references — the `\%hash` operator that takes a reference to a hash, and the `%$ref` / `$ref->{key}` syntax used to dereference one — since a hash of hashes is, under the surface, a hash whose values are references.
- A hash of hashes, `%contacts`, keyed by name with `phone`, `email`, and `notes` fields per entry.
- An `add_contact()` subroutine that stores a new entry using a hash reference.
- A `search_contacts()` subroutine matching on a case-insensitive substring of the name.
- A `save_contacts()` subroutine that serializes every entry to a pipe-delimited text file.
- A `load_contacts()` subroutine that rebuilds `%contacts` from that same file on startup.
- A menu loop tying add/search/list/save/load together into one running program.
Prerequisites
- Hashes — `%hash`, `$hash{$key}`, and iterating with `keys`/`each`.
- References — taking a reference with `\`, and dereferencing with `->` or `%$ref`/`@$ref`.
- Regular expressions — case-insensitive substring matching with the `/i` flag.
- File handling — reading and writing text files with `open`/`close`.
- Subroutines — passing and returning references as arguments.
Project Structure
The project is one script, `contact_manager.pl`, plus the data file it reads from and writes to, `contacts.txt`. `%contacts` is declared once near the top and passed around by reference to every subroutine that needs it, rather than being read as a global from inside each one — `add_contact(\%contacts, ...)` makes it explicit at the call site exactly which data structure a subroutine is going to modify.
Contacts are persisted as plain pipe-delimited text, one contact per line: `name|phone|email|notes`. A pipe character is used as the delimiter instead of a comma because notes or addresses are more likely to contain a comma than a pipe, and choosing a low-collision delimiter up front avoids a whole class of parsing bugs later — the same reasoning that leads real-world tools toward tab-delimited or explicitly escaped formats.
Step 1: Model a Contact as a Hash of Hashes
`%contacts` is declared as an ordinary hash, but every value stored in it is a hash reference rather than a plain scalar — that is the entire mechanism behind "hash of hashes." `\%contacts` (backslash before the sigil) takes a reference to the whole outer hash, which is what gets passed into every subroutine below instead of a copy of the data.
use strict;use warnings;
# %contacts maps a name (string) to a HASH REFERENCE holding that contact's# fields. Each value is created with {...}, which is hash-reference syntax —# not to be confused with the (...) syntax used for a plain list.my %contacts;
# Example of the shape being built, without add_contact() yet:# $contacts{"Aditi Rao"} = { phone => "555-0142", email => "aditi@example.com", notes => "Met at conference" };## Reading a single field back out requires the arrow: $contacts{"Aditi Rao"}{phone}# Perl allows dropping the arrow between adjacent {}{} pairs, so# $contacts{"Aditi Rao"}->{phone} and $contacts{"Aditi Rao"}{phone} are equivalent.Step 2: Add a Contact
`add_contact()` receives a reference to `%contacts` as its first argument — `$contacts_ref` — rather than the hash itself, so any change made inside the subroutine is visible to the caller immediately, without needing a return value to hand the updated hash back. `$contacts_ref->{$name}` dereferences the outer hash to reach (or create) the entry for `$name`, and assigns a fresh anonymous hash reference, `{ phone => ..., email => ..., notes => ... }`, as that entry's value.
sub add_contact { my ($contacts_ref, $name, $phone, $email, $notes) = @_; # $contacts_ref is a reference, not a copy $contacts_ref->{$name} = { # -> dereferences; {...} builds a new hash reference phone => $phone, email => $email, notes => $notes // '', # // (defined-or) falls back to '' if $notes was never supplied }; print "Added contact: $name\n";}
sub print_contact { my ($contacts_ref, $name) = @_; my $c = $contacts_ref->{$name}; # $c is now a reference to that one contact's inner hash return unless $c; # Nothing to print if $name isn't a key in %contacts print "Name: $name\n"; print "Phone: $c->{phone}\n"; # Same arrow-dereference syntax, one level in print "Email: $c->{email}\n"; print "Notes: $c->{notes}\n" if $c->{notes};}Click Run to see what this code prints.
Step 3: Search Contacts
`search_contacts()` walks every key in `%contacts` with `keys %$contacts_ref` — dereferencing the hash reference back into a plain hash so `keys` can operate on it — and checks each name against the query case-insensitively with `/i`. `quotemeta()` reappears here for the same reason it did in the Find & Replace project: a query typed by a person at a menu prompt should be matched literally, not interpreted as regex syntax if it happens to contain a character like `.` or `(`.
sub search_contacts { my ($contacts_ref, $query) = @_; my $pattern = quotemeta($query); my @matches; foreach my $name (keys %$contacts_ref) { # %$contacts_ref dereferences the hash ref back into a plain hash if ($name =~ /$pattern/i) { # /i makes the match case-insensitive push @matches, $name; } } return sort @matches; # Alphabetical order makes results predictable to read}Step 4: Save Contacts to a File
`save_contacts()` writes one line per contact in `name|phone|email|notes` format, opening the output file with `'>'` which truncates and overwrites it fresh on every save rather than appending to whatever was there before. `keys %$contacts_ref` supplies the names to iterate, and `$contacts_ref->{$name}{phone}` (arrow dropped between the two `{}` pairs, which Perl allows) reaches each field to print.
sub save_contacts { my ($contacts_ref, $path) = @_; open(my $fh, '>', $path) # '>' truncates and overwrites; use '>>' instead if appending were ever needed or die "Could not open '$path' for writing: $!\n"; foreach my $name (sort keys %$contacts_ref) { my $c = $contacts_ref->{$name}; # Pipe-delimited: a comma is far more likely to appear inside notes than a pipe is. print $fh join('|', $name, $c->{phone}, $c->{email}, $c->{notes}) . "\n"; } close($fh); print "Saved " . scalar(keys %$contacts_ref) . " contact(s) to $path\n";}Step 5: Load Contacts From a File
`load_contacts()` is `save_contacts()` run in reverse: it reads the file one line at a time, splits each line back into its four fields on the `|` delimiter, and rebuilds one hash-of-hashes entry per line. `split(/\|/, $line, -1)` uses an escaped pipe (`\|`, since a bare `|` is regex alternation syntax) and a `-1` limit so a trailing empty `notes` field is preserved instead of being silently dropped, which `split` would otherwise do by default.
sub load_contacts { my ($contacts_ref, $path) = @_; return unless -e $path; # -e is a file test operator: true if the file exists; nothing to load otherwise open(my $fh, '<', $path) or die "Could not open '$path' for reading: $!\n"; %$contacts_ref = (); # Clear any existing in-memory contacts before loading fresh ones while (my $line = <$fh>) { chomp $line; next unless $line; # Skip blank lines # -1 as the third argument to split preserves a trailing empty field (e.g. empty notes) my ($name, $phone, $email, $notes) = split(/\|/, $line, -1); $contacts_ref->{$name} = { phone => $phone, email => $email, notes => $notes }; } close($fh); print "Loaded " . scalar(keys %$contacts_ref) . " contact(s) from $path\n";}Step 6: Build the Menu Loop
The menu loop calls `load_contacts()` once at startup so contacts persist across runs, and offers add/search/list/save/exit as numbered options, reading each with `chomp(my $choice = <STDIN>)` — `<STDIN>` reads one line from standard input, and wrapping the assignment inside `chomp()`'s parentheses strips the trailing newline in the same statement it is read. Every menu action operates on the single shared `%contacts` hash by passing `\%contacts` into whichever subroutine handles it.
my $data_file = 'contacts.txt';load_contacts(\%contacts, $data_file); # Restore contacts saved by a previous run, if any
my $choice = '';while ($choice ne '5') { print "\n===== CONTACT LIST MANAGER =====\n"; print "1. Add Contact\n"; print "2. Search Contacts\n"; print "3. List All Contacts\n"; print "4. Save Contacts\n"; print "5. Exit\n"; print "Enter your choice: "; chomp($choice = <STDIN>); # Read one line from standard input and strip its trailing newline
if ($choice eq '1') { print "Name: "; chomp(my $name = <STDIN>); print "Phone: "; chomp(my $phone = <STDIN>); print "Email: "; chomp(my $email = <STDIN>); print "Notes: "; chomp(my $notes = <STDIN>); add_contact(\%contacts, $name, $phone, $email, $notes); } elsif ($choice eq '2') { print "Search query: "; chomp(my $query = <STDIN>); my @matches = search_contacts(\%contacts, $query); if (@matches) { print "\nFound " . scalar(@matches) . " match(es):\n"; print_contact(\%contacts, $_) and print "\n" foreach @matches; } else { print "No matches found.\n"; } } elsif ($choice eq '3') { print "\n--- All Contacts (" . scalar(keys %contacts) . ") ---\n"; foreach my $name (sort keys %contacts) { print_contact(\%contacts, $name); print "\n"; } } elsif ($choice eq '4') { save_contacts(\%contacts, $data_file); } elsif ($choice eq '5') { save_contacts(\%contacts, $data_file); # Save automatically on exit so nothing is lost print "Goodbye!\n"; } else { print "Invalid choice, try again.\n"; }}Complete Code
Here is the full script assembled in the order it runs, ready to save as `contact_manager.pl` and run with `perl contact_manager.pl`.
use strict;use warnings;
my %contacts;
sub add_contact { my ($contacts_ref, $name, $phone, $email, $notes) = @_; $contacts_ref->{$name} = { phone => $phone, email => $email, notes => $notes // '', }; print "Added contact: $name\n";}
sub print_contact { my ($contacts_ref, $name) = @_; my $c = $contacts_ref->{$name}; return unless $c; print "Name: $name\n"; print "Phone: $c->{phone}\n"; print "Email: $c->{email}\n"; print "Notes: $c->{notes}\n" if $c->{notes};}
sub search_contacts { my ($contacts_ref, $query) = @_; my $pattern = quotemeta($query); my @matches; foreach my $name (keys %$contacts_ref) { if ($name =~ /$pattern/i) { push @matches, $name; } } return sort @matches;}
sub save_contacts { my ($contacts_ref, $path) = @_; open(my $fh, '>', $path) or die "Could not open '$path' for writing: $!\n"; foreach my $name (sort keys %$contacts_ref) { my $c = $contacts_ref->{$name}; print $fh join('|', $name, $c->{phone}, $c->{email}, $c->{notes}) . "\n"; } close($fh); print "Saved " . scalar(keys %$contacts_ref) . " contact(s) to $path\n";}
sub load_contacts { my ($contacts_ref, $path) = @_; return unless -e $path; open(my $fh, '<', $path) or die "Could not open '$path' for reading: $!\n"; %$contacts_ref = (); while (my $line = <$fh>) { chomp $line; next unless $line; my ($name, $phone, $email, $notes) = split(/\|/, $line, -1); $contacts_ref->{$name} = { phone => $phone, email => $email, notes => $notes }; } close($fh); print "Loaded " . scalar(keys %$contacts_ref) . " contact(s) from $path\n";}
my $data_file = 'contacts.txt';load_contacts(\%contacts, $data_file);
my $choice = '';while ($choice ne '5') { print "\n===== CONTACT LIST MANAGER =====\n"; print "1. Add Contact\n"; print "2. Search Contacts\n"; print "3. List All Contacts\n"; print "4. Save Contacts\n"; print "5. Exit\n"; print "Enter your choice: "; chomp($choice = <STDIN>);
if ($choice eq '1') { print "Name: "; chomp(my $name = <STDIN>); print "Phone: "; chomp(my $phone = <STDIN>); print "Email: "; chomp(my $email = <STDIN>); print "Notes: "; chomp(my $notes = <STDIN>); add_contact(\%contacts, $name, $phone, $email, $notes); } elsif ($choice eq '2') { print "Search query: "; chomp(my $query = <STDIN>); my @matches = search_contacts(\%contacts, $query); if (@matches) { print "\nFound " . scalar(@matches) . " match(es):\n"; print_contact(\%contacts, $_) and print "\n" foreach @matches; } else { print "No matches found.\n"; } } elsif ($choice eq '3') { print "\n--- All Contacts (" . scalar(keys %contacts) . ") ---\n"; foreach my $name (sort keys %contacts) { print_contact(\%contacts, $name); print "\n"; } } elsif ($choice eq '4') { save_contacts(\%contacts, $data_file); } elsif ($choice eq '5') { save_contacts(\%contacts, $data_file); print "Goodbye!\n"; } else { print "Invalid choice, try again.\n"; }}Sample Run
Click Run to see what this code prints.
Extend This Project
- Add an `update_contact()` and `delete_contact()` subroutine, both operating on the shared `\%contacts` reference like `add_contact()` does.
- Add a "Search by phone" or "Search by email" option that scans the inner hash fields instead of just the outer `$name` keys.
- Switch the file format from pipe-delimited text to `Data::Dumper` or `JSON::PP` so nested structures serialize automatically.
- Add duplicate-name detection to `add_contact()` that warns before overwriting an existing entry.
- Extend the inner hash with an `array reference` field, `tags => ['work', 'friend']`, to see a hash of hashes containing an array reference one level deeper.
Summary
You built a contact manager where every entry is a hash reference living inside an outer hash, `%contacts`, and every subroutine operates on that shared structure through a reference passed as its first argument rather than through a global variable. That pattern — a complex data structure built from nested hash/array references, threaded through subroutines by reference instead of by copy — is the foundation almost every non-trivial Perl program is built on top of.