LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
PerlIntermediate~2 hours

Contact List Manager

Store and search contacts using a hash of hashes, saved to a text file.

Complex Data StructuresReferencesFile Handling

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.

What You'll Build
  • 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};
}
Example Usage

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

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.