LearnAI ToolsCareerPractice BuildsPlayContact
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.