Demo: saving and loading contacts as CSV
On This Lesson 11 topics
Lesson Overview
This is the demo lesson of the module. You have all the pieces: relative paths and Files, reading and writing text, buffered readers in try-with-resources, UTF-8, and a simple CSV format. Now you will put them together into the heart of a contacts program: it loads contacts from a file when it starts, and saves them when it ends, so the data survives a restart.
Follow along by typing the code yourself. We will build it in steps, make one deliberate mistake on purpose, and then fix it. At the end you will deliberately damage the data file and see that the program reports the bad lines instead of falling over. This is the program that the module's lab (BEG-034-LAB1, contacts persistence) asks you to build in your own project, so treat this demo as the template to adapt, not to copy.
Why this matters for a backend: almost every service has some state that must outlive the process, and the first time you persist state you learn the same three lessons shown here: the file may be missing, the file may be damaged, and the path must not depend on one computer. Databases come later in the course; the discipline is the same.
The previous lesson, "A simple CSV format", gave you toCsv and fromCsv. This lesson closes the module.
Learning Objectives
By the end of this lesson you will be able to:
- Write a
savemethod that turns a list of contacts into CSV lines and writes them as UTF-8. - Write a
loadmethod that returns an empty list for a missing file. - Parse each line separately, and count and report bad lines instead of crashing.
- Replace a hard-coded absolute path with a relative path built from
Path.of. - Show that data survives a restart by running the program more than once.
- Explain a risk of the save-on-exit design: saving rewrites the whole file, including the loss of lines that were skipped.
Written Explanation
The plan
The design has four small parts, each from an earlier lesson:
- A
ContactwithtoCsv()andfromCsv(line), which rejects wrong lines withIllegalArgumentException. save(List<Contact>, Path): build the lines, make sure the folder exists, write as UTF-8.load(Path): if the file is missing, return an empty list. Otherwise read line by line, parse each line inside its owntry/catch, count the bad ones, print their numbers and reasons.- A
mainthat loads at the start, applies a change, and saves at the end.
Step 1: save
static void save(List<Contact> contacts, Path file) throws IOException {
List<String> lines = new ArrayList<>();
for (Contact c : contacts) {
lines.add(c.toCsv());
}
Files.createDirectories(file.toAbsolutePath().getParent());
Files.write(file, lines, StandardCharsets.UTF_8);
}We build the list of lines, create the folder if needed, and write. Files.write replaces the file, which is correct here: the list in memory is the complete truth, so the file should become an exact copy of it. The call file.toAbsolutePath().getParent() avoids the null problem from the first lesson: an absolute path always has a parent, even if the relative path was just contacts.csv.
Step 2: load
static LoadResult load(Path file) {
List<Contact> contacts = new ArrayList<>();
if (!Files.exists(file)) {
return new LoadResult(contacts, 0);
}
int bad = 0;
try (BufferedReader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
String line;
int number = 0;
while ((line = reader.readLine()) != null) {
number++;
if (line.isBlank()) continue;
try {
contacts.add(Contact.fromCsv(line));
} catch (IllegalArgumentException e) {
bad++;
System.out.println("Line " + number + " skipped: " + e.getMessage());
}
}
} catch (IOException e) {
System.out.println("Could not read " + file + ": " + e.getMessage());
}
return new LoadResult(contacts, bad);
}Study the structure: there are two nested levels of error handling, for two different kinds of failure. The inner catch (IllegalArgumentException) handles one bad line and lets the loop continue. The outer catch (IOException) handles trouble with the file itself (it exists but cannot be read, or contains bytes that are not valid UTF-8) and returns whatever was loaded so far. A missing file is not an error at all: it is the normal first run. The method returns both the list and the count of bad lines, using a small LoadResult record, so that the caller can tell the user.
Step 3: the planned mistake, absolute paths
Suppose we first wrote the file location like this:
static final Path FILE = Path.of("C:\\data\\contacts.csv"); // wrongOn the author's machine it may work. On another computer it fails in one of several ways: on Linux or macOS the string is not a sensible path at all (it would be treated as a single odd file name), and on a Windows machine without that folder or drive the save throws an exception. To show a failure safely, we pointed the save at a drive that does not exist on this machine, Z:/data/contacts.csv, and got:
FileSystemException: Z:\data: Unable to determine if root directory existsThe exact message depends on the operating system, so do not expect to see this same text. The point is that the program only works where the author's folder layout happens to exist. The fix is the relative path:
static final Path FILE = Path.of("data", "contacts.csv"); // rightNow the file lives in a data folder inside the working directory, and save creates the folder if it is missing.
Step 4: run it twice
The main method loads, optionally adds a contact given on the command line, saves, and prints. Running it several times (for example java Contacts add Ana 0501112222 ana@example.com, adapted to how you launch programs) shows the data growing between runs.
Examples
Example 1: the first run with no file (simple)
The complete program is in Example 3. Here is what the first two runs print when the data folder does not exist yet.
Loaded 0 contacts, 0 bad linesLoaded 0 contacts, 0 bad lines
Contact[name=Ana, phone=0501112222, email=ana@example.com]The first run only loaded and saved. The second added Ana and listed her. Nothing crashed although the file and folder were missing at the start.
Example 2: data survives a restart (practical)
Running the program again, as a completely new process, with a second contact:
Loaded 1 contacts, 0 bad lines
Contact[name=Ana, phone=0501112222, email=ana@example.com]
Contact[name=Omar, phone=0503334444, email=]The first line proves it: the new process started with an empty list in memory and still found Ana, because load read her from data/contacts.csv. The file now contains:
Ana,0501112222,ana@example.com
Omar,0503334444,Omar's empty email is the trailing empty field from the CSV lesson, and it survived the round trip thanks to split(",", -1).
Example 3: the full program, with a damaged file (realistic)
Here is the complete program (it uses record for brevity; the validation lives in a factory method Contact.of, so the same rules apply to typed input and to loaded lines):
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
public class Contacts {
record Contact(String name, String phone, String email) {
static Contact of(String name, String phone, String email) {
if (name.isBlank()) {
throw new IllegalArgumentException("name is empty");
}
for (String f : List.of(name, phone, email)) {
if (f.contains(",")) {
throw new IllegalArgumentException("commas are not allowed: " + f);
}
}
return new Contact(name.strip(), phone.strip(), email.strip());
}
String toCsv() {
return String.join(",", name, phone, email);
}
static Contact fromCsv(String line) {
String[] f = line.split(",", -1);
if (f.length != 3) {
throw new IllegalArgumentException("expected 3 fields but found " + f.length);
}
return of(f[0], f[1], f[2]);
}
}
record LoadResult(List<Contact> contacts, int badLines) {}
static final Path FILE = Path.of("data", "contacts.csv");
// save(...) and load(...) exactly as in Steps 1 and 2
public static void main(String[] args) throws IOException {
LoadResult result = load(FILE);
List<Contact> contacts = result.contacts();
System.out.println("Loaded " + contacts.size() + " contacts, " + result.badLines() + " bad lines");
if (args.length == 4 && args[0].equals("add")) {
try {
contacts.add(Contact.of(args[1], args[2], args[3]));
} catch (IllegalArgumentException e) {
System.out.println("Not added: " + e.getMessage());
}
}
save(contacts, FILE);
for (Contact c : contacts) {
System.out.println(c);
}
}
}Now damage the file on purpose: append the lines Broken, an empty line, Lee,0507,l@x, and a,b,c,d to data/contacts.csv with any editor, and run the program. Output:
Line 3 skipped: expected 3 fields but found 1
Line 6 skipped: expected 3 fields but found 4
Loaded 3 contacts, 2 bad lines
Contact[name=Ana, phone=0501112222, email=ana@example.com]
Contact[name=Omar, phone=0503334444, email=]
Contact[name=Lee, phone=0507, email=l@x]Two bad lines were reported with their numbers and the good ones, including Lee after the blank line, still loaded. Trying to add Smith, John printed Not added: commas are not allowed: Smith, John and the program continued. Now notice the catch: the program saves on exit, so the file was rewritten from the list in memory, and the two bad lines are gone from the file. A second run printed Loaded 3 contacts, 0 bad lines. The report was shown once, and the evidence was erased. See the exercises for how to improve this.
Backend Insight
This demo is a miniature of what a repository (a class that stores and retrieves your objects) does in a real service, and the same questions return in every persistence layer. What happens on first start with no data? What happens when the stored data is damaged? What happens if the program crashes in the middle of saving?
Our Files.write truncates the file before writing, so a crash in the middle of a save can leave a half-written file. Production systems often write to a temporary file and swap it into place, or use a database, which handles this for you. We are not doing that here; it is worth knowing the weakness exists.
Common Mistakes
Mistake 1: Hard-coded absolute paths
- Incorrect:
Path.of("C:\\data\\contacts.csv"). - Correct:
Path.of("data", "contacts.csv")andFiles.createDirectoriesbefore the first write.
Mistake 2: Crashing on a missing file
A missing file on the first run is normal. load should check Files.exists (or catch NoSuchFileException) and return an empty list.
Mistake 3: One try/catch around the whole loop
A single bad line then aborts the load. Parse each line inside its own try/catch.
Mistake 4: Hiding the bad lines
Skipping bad lines without a message makes data loss invisible. Print the line number and the reason, and count them.
Exercises
Try each exercise yourself before opening the solutions. The Contacts program above is the starting point.
Level A: Foundation
A1. What does load return if data/contacts.csv does not exist, and why is that not treated as an error?
Show solution A1
A1. It returns an empty list with 0 bad lines. A missing file is the normal situation on the very first run, so it is not an error.
A2. In the damaged-file run, why is the bad line a,b,c,d reported as line 6 and not line 4?
Show solution A2
A2. The loop counts physical lines of the file, including blank ones. The file has the lines Ana..., Omar..., Broken, an empty line, Lee..., and a,b,c,d, so a,b,c,d is the sixth line, and Broken is the third. The blank line counts but is skipped.
A3. Why is Path.of("data", "contacts.csv") better than Path.of("C:\\data\\contacts.csv")?
Show solution A3
A3. The relative path works on any computer and operating system when run from the project folder, and Java supplies the correct separator. The absolute path exists only on one machine.
Level B: Application
B1. Write static boolean removeByName(List<Contact> list, String name) that removes contacts whose name equals name, ignoring case, and returns whether anything was removed. (List.removeIf takes a condition.)
Show solution B1
B1.
static boolean removeByName(List<Contact> list, String name) {
return list.removeIf(c -> c.name().equalsIgnoreCase(name));
}Removing omar returned true, and removing nobody returned false (run and verified). The -> expression is a lambda; you will study these formally later, and here it is only a short way to write the condition.
B2. Write static int loadKeepingRejects(Path file, Path rejects, List<Contact> out) throws IOException that loads good contacts into out, writes the bad lines to the rejects file (only if there are any), and returns how many were bad.
Show solution B2
B2.
static int loadKeepingRejects(Path file, Path rejects, List<Contact> out) throws IOException {
List<String> bad = new ArrayList<>();
for (String line : Files.readAllLines(file, StandardCharsets.UTF_8)) {
if (line.isBlank()) continue;
try {
out.add(Contact.fromCsv(line));
} catch (IllegalArgumentException e) {
bad.add(line);
}
}
if (!bad.isEmpty()) {
Files.write(rejects, bad, StandardCharsets.UTF_8);
}
return bad.size();
}On a file with Broken and a,b,c,d among three good lines, it returned 2, loaded 3 contacts, and wrote the two bad lines to the rejects file. readAllLines is acceptable here because the file is small; the caller must also handle a missing file.
B3. Make main print Your file had N bad lines; see data/contacts.rejected.txt when there were bad lines. Which methods need to change?
Show solution B3
B3. main needs the returned count (or LoadResult.badLines()), and a message such as if (bad > 0) System.out.println("Your file had " + bad + " bad lines; see data/contacts.rejected.txt");. The load method must be the one that writes the rejects, so it changes too.
Level C: Challenge
C1. Write static void saveWithBackup(List<Contact> list, Path file) throws IOException that, before saving, copies the existing file's lines to <file>.bak (when the file exists), then saves as usual.
Show solution C1
C1.
static void saveWithBackup(List<Contact> list, Path file) throws IOException {
if (Files.exists(file)) {
Files.write(Path.of(file + ".bak"),
Files.readAllLines(file, StandardCharsets.UTF_8), StandardCharsets.UTF_8);
}
save(list, file);
}Running it after the damaged-file case produced a .bak file containing the old lines, including the bad ones, and a clean new file (verified by running). Each save overwrites the previous backup, so it keeps only one generation.
C2. Sort the contacts by name before saving so the file is always in alphabetical order. Which earlier module taught the tool, and where in the program does the sorting belong?
Show solution C2
C2. Sorting with a Comparator was taught in the comparators module. In the demo it worked with list.sort(Comparator.comparing(Contact::name)), which sorted Ana before Lee. It belongs just before the save, in main or at the start of save, so that the file is sorted regardless of how the list got its order. The other approach, sorting right after loading, would leave the file unsorted until the next run.
Mini Challenge
Adapt the demo into your own small program, a book list or a to-do list with at least three fields. It must: load at the start and save at the end; create the data folder when missing; survive a missing file on the first run; report bad lines with their numbers; keep the rejected lines in a separate file; and use UTF-8 and a relative path everywhere. Test it by running it three times, then damage the file by hand with one short line and one line with too many fields, and run it again.
Check your work: no absolute path anywhere, no empty catch block, the reader is in try-with-resources, the second run shows the data from the first, and a damaged file produces messages instead of a stack trace. This is the starting point for BEG-034-LAB1.
Summary & Next Lesson
Key takeaways
- Persistence is two methods:
save(list to lines to file) andload(file to lines to list). Keep every other part of the program unaware of the file. - A missing file on the first run is normal:
loadreturns an empty list. - Parse each line in its own
try/catch, and report bad lines with their numbers instead of crashing. - Use a relative path built with
Path.of, UTF-8, andtry-with-resources. - Saving the whole list rewrites the file, which erases lines that were skipped. Keep rejected lines or a backup if the data matters.
Next lesson
This was the last lesson of the module. The module lab, "Contacts persistence" (BEG-034-LAB1), asks you to build this behaviour into your own contacts program.
Self-check
- What should
loaddo when the file does not exist? - Why is the
try/catchfor a bad line inside the loop? - What kind of exception does the outer
catchinloadhandle, and what does it protect against? - Why did the second run after damaging the file show 0 bad lines?
- Name two reasons to prefer
Path.of("data", "contacts.csv")over an absolute path.
Show answers
- Return an empty list (and zero bad lines), because that is normal on the first run.
- So a bad line is reported and skipped, while the loop continues to the remaining lines.
IOException. It covers trouble with the file itself, such as being unreadable or not valid UTF-8, and lets the program carry on with what it loaded.- The first run rewrote the file from the list in memory when it saved, so the bad lines were no longer in the file.
- It works on other computers and operating systems, and Java picks the right separator. (A third: the folder can be created by the program.)
References
- Oracle, Files (Java SE 21 API). It supports the behaviour of
writeandnewBufferedReader(UTF-8 default, default write options that truncate),createDirectories, thatnewBufferedReadermust be closed by the caller, and thatIOExceptionis reported for malformed input. - Oracle, Path (Java SE 21 API). It supports
Path.ofwith the system separator, andtoAbsolutePathresolving a relative path against the working directory. - Oracle, BufferedReader (Java SE 21 API). It supports
readLine()returningnullat the end and use with try-with-resources. - Everything shown as program output came from running the code on JDK 21 on Windows. The failing message for a non-existent drive is operating-system specific, and the statement about what a Windows-style path means on Linux or macOS is general knowledge that I did not test.
Practice
Apply this lesson with the module's lab, exercises and quiz.
Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.