Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

File Handling

Learning Objectives

  • Be able to read and write text files using Java's Files class.
  • Be able to use the Scanner class to read and parse data from files.
  • Be able to process files line by line using the Stream interface.
  • Know the basics of the JSON file format.
  • Be able to use the Jackson library to read and write JSON data.
  • Understand how Java records are suitable for modeling data.

File handling always follows the same basic pattern: open a resource, read or write data in a particular format, and close the resource. Java provides several built-in alternatives for this. The choice depends on whether you only need to read data line by line or whether you need to split and parse lines into values (such as numbers), whether you want to process large files efficiently, and what format the data is stored in.

Adding Your Own File to a Project

Suppose we have the following text file:

name,age
Maija,25
Matti,30

The file contains information about people. The first line contains column names, while the following lines contain person data. The values are separated by commas. This file format is called a CSV file (comma-separated values), and it is a very common way of storing tabular data in a text file.

Save such a file as data.csv in the root directory of the project. To allow IDEA to access the file when running the program, define the project's root directory as the working directory. This can be done via run Edit Configurations. Select the class containing the main method. In the Working directory field, make sure the directory points to the root of the source code, usually ending in src or src/main/java.

Now we can use the data.csv file in our program.


Processing Files with the Files API

The Files class (more specifically, the API in the java.nio.file package) provides a straightforward way to read an entire file at once when the file size is reasonably small. You can, for example, read the entire file into memory as a list of lines using Files.readAllLines or as a single string using Files.readString. If the file contains numbers, dates, or other structured data, those values must still be processed separately.

Let's implement the earlier example using Files.readAllLines. This method reads the entire file into memory as a list of strings, where each string corresponds to one line in the file. We can then iterate through the list and split each line into columns.

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.List;

public class FileReader {
    public static void main(String[] args) {
        try {
            List<String> lines = Files.readAllLines(Paths.get("data.csv"));
            for (int i = 1; i < lines.size(); i++) {
                String line = lines.get(i);
                String[] parts = line.split(",");
                String name = parts[0];
                int age = Integer.parseInt(parts[1]);
                IO.println("Name: " + name + ", Age: " + age);
            }
        } catch (IOException e) {
            IO.println("File not found or cannot be read: " + e.getMessage());
        } finally {
            // Nothing needs to be closed manually because the Files API handles it for us
        }
    }
}

Using the same approach—processing the entire file at once—you can also write data to a file. When the entire contents are available as a single string, you can use Files.writeString. Before writing, you must ensure that the destination directory exists.

Path path = Path.of("data", "result.txt");
Files.createDirectories(path.getParent());
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

public class WriteFileWriteString {
    public static void main(String[] args) {
        Path path = Path.of("data", "result.txt");

        try {
            Files.createDirectories(path.getParent()); // Make sure the data folder exists

            String content = "Hello!\nThis is a new file.\n";
            Files.writeString(path, content, StandardCharsets.UTF_8);

            IO.println("Written to: " + path.toAbsolutePath());

        } catch (IOException e) {
            IO.println("Writing failed: " + e.getMessage());
        }
    }
}

When the data naturally exists as lines, it is often convenient to use Files.write().

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;

public class WriteFileLineByLine {
    public static void main(String[] args) {
        Path path = Path.of("data", "data.csv");

        List<String> lines = List.of(
                "name,age",
                "Maija,25",
                "Matti,30"
        );

        try {
            Files.createDirectories(path.getParent());
            Files.write(path, lines, StandardCharsets.UTF_8);

            IO.println("Written to: " + path.toAbsolutePath());

        } catch (IOException e) {
            IO.println("Writing failed: " + e.getMessage());
        }
    }
}

It is also possible to append text to the end of an existing file. This requires a few extra arguments.

// ...

Path path = Path.of("data", "result.txt");

String line = "A new line appended to the end of the file.\n";

Files.writeString(
        path,
        line,
        StandardCharsets.UTF_8,         // UTF-8 encoding
        StandardOpenOption.CREATE,      // Create a file if it doesn't exists
        StandardOpenOption.APPEND       // Append to the end of a file
);

// ...

Reading with a Scanner

Scanner is well suited for situations where you want to read text piece by piece: for example, one line at a time, up to the next whitespace, or even the next number. You can think of a Scanner object as a reading head or cursor that advances whenever methods such as nextLine or next are called. When there is nothing left to read, hasNext returns false.

Scanner can also read numbers and other primitive types directly (nextInt, nextDouble, etc.), reducing the amount of manual parsing required.

The following example reads a file using a Scanner. Reading is performed line by line, and each line is split into columns.

import java.io.File;
import java.io.FileNotFoundException;
import java.util.Scanner;

public class FileReader {
    public static void main(String[] args) {
        Scanner scanner = null;

        try {
            scanner = new Scanner(new File("data.csv"));

            // Skip header row
            if (scanner.hasNextLine()) {
                scanner.nextLine();
            }

            // Read lines until the end of file
            while (scanner.hasNextLine()) {
                String line = scanner.nextLine();
                String[] parts = line.split(",");
                String name = parts[0];
                int age = Integer.parseInt(parts[1]);
                IO.println("Name: " + name + ", Age: " + age);
            }

        } catch (FileNotFoundException e) {
            IO.println("File not found: " + e.getMessage());
        } finally {
            // Close the scanner
            if (scanner != null) {
                scanner.close();
            }
        }
    }
}

Data is not always this nicely formatted. A file might contain numbers separated by spaces, commas, semicolons, or line breaks.

Suppose the file measurements.txt contains:

12  8,  5
-3; 10  7
error  2  1.5  3

You want to read all numeric values regardless of how they are separated. This is somewhat awkward with the Files API but is easy with Scanner. Using useDelimiter, you can define which characters act as separators. For example useDelimiter("[\\s,;]+") defines spaces, commas, semicolons, and line breaks as delimiters. Everything between delimiters forms a token, which can be read using next.

import java.io.File;
import java.io.IOException;
import java.util.Locale;
import java.util.Scanner;
public class ReadNumbersWithScanner {
    public static void main(String[] args) throws IOException {
        double sum = 0.0;
        int count = 0;

        Scanner sc = new Scanner(new File("measurements.txt"));
        try {
            sc.useLocale(Locale.US);  
            sc.useDelimiter("[\\s,;]+");

            while (sc.hasNext()) {
                if (sc.hasNextDouble()) {
                    sum += sc.nextDouble();
                    count++;
                } else {
                    sc.next();  // Skip token which isn't a valid dumber
                }
            }
        } finally {
            sc.close();
        }

        IO.println("Numbers: " + count);
        IO.println("Sum: " + sum);
        IO.println("Average: " + (count == 0 ? 0 : sum / count));
    }
}

A Scanner can only read files; it cannot write to them.


Streams

In addition to collections (see Chapter 6.2), files and other external resources can also be processed using the Stream interface. Streams are particularly useful when multiple sequential operations are performed on data, such as transformations (map), filtering (filter), and collecting (toList, collect). The processing is described as a chain that clearly communicates what happens to the data step by step.

Reading a file as a stream typically begins with Files.lines(path). Unlike readAllLines, which loads the entire file into memory, Files.lines reads lines lazily as the stream is consumed. This makes it suitable even for large files. Since the file remains open while reading, the stream must eventually be closed.

Let's start with a simple example that repeats the earlier pattern, but now uses Files.lines and stream processing. We use the previously learned map operation to transform each row into an array. As the terminal operation, we use forEach, which executes the given lambda expression for each row. Here we parse the rows in the same way as in the earlier examples and finally print the names and ages.

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;

public class FileReaderStream {
    static void main() {
        try {
            Files.lines(Paths.get("data.csv"))
                    .skip(1) // Skip the header row
                    .map(line -> line.split(",")) // Split the row into columns
                    .forEach(parts -> {
                        String name = parts[0]; // First column is the name
                        int age = Integer.parseInt(parts[1]); // Second column is the age, parse as int
                        IO.println("Name: " + name + ", Age: " + age);
                    });
        } catch (IOException e) {
            IO.println("File not found or cannot be read: " + e.getMessage());
        }
    }
}

Let's extend the example a little. Filter out people younger than 18 and finally print the names in alphabetical order.

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.List;

public class FileReaderStream {
    static void main() {
        try {

List<String> names = Files.lines(Paths.get("data.csv"))
        .skip(1) // Skip the header row
        .map(line -> line.split(",")) // Split the row into columns
// HIGHLIGHT_GREEN_BEGIN
        .filter(parts -> Integer.parseInt(parts[1]) >= 18) // Filter out people under 18
        .map(parts -> parts[0]) // Keep only the name
        .sorted() // Sort names alphabetically
        .toList(); // Collect results into a list

names.forEach(IO::println); // Print the names
// HIGHLIGHT_GREEN_END

        } catch (IOException e) {
            IO.println("File not found or cannot be read: " + e.getMessage());
        }
    }
}

Stream-based processing is very convenient when the processing is relatively simple and progresses linearly. However, it is worth noting that stream-based processing can significantly complicate debugging.

Optional information: More about challenges of stream-based processing
  • Single-use nature: A Stream object can only be used once, after which it must be closed. If you want to process the same data again, you must create a new stream.
  • Debugging: Chaining hides intermediate results. If a map or filter step throws an exception, the stack trace tells you which lambda was being executed, but not necessarily which row or intermediate value caused the problem without additional logging or the use of the peek method.
  • Error handling: Unchecked exceptions inside lambda expressions (for example, NumberFormatException in a call to Integer.parseInt) must be handled separately because lambda expressions do not allow checked exceptions to be thrown directly. This can make error handling more complicated than in a traditional loop.
  • Laziness can be surprising: A stream does nothing until a terminal operation (forEach, toList, collect, count, etc.) is executed. This can be surprising because the code appears to read a file, yet nothing happens if the terminal operation is missing. Likewise, exceptions are not thrown when the file is opened but only when the stream is consumed.

BufferedReader and BufferedWriter

BufferedReader and BufferedWriter are the traditional tools for processing text files when you want to read and write line by line and maintain precise control over the processing loop. They buffer I/O operations, meaning they do not perform a system call for every individual character but instead read and write larger chunks of data. This improves performance, especially when working with large files, and makes processing more predictable. We leave studying these classes as an optional, self-directed exercise.


JSON Files

JSON (JavaScript Object Notation) is a popular data exchange format used extensively in web development. JSON files are text files containing key-value pairs and may contain complex data structures such as arrays and objects.

JSON can contain the following types of values:

  • String ("Maija")
  • Number (25)
  • Boolean (true / false)
  • Null (null)
  • Array ([ ... ])
  • Object ({ ... })

For example, the file people.json could look like this:

[
  {
    "name": "Maija",
    "age": 25,
    "city": "Jyväskylä"
  },
  {
    "name": "Matti",
    "age": 30,
    "city": "Tampere"
  }
]

Compared to CSV, the advantage of JSON is that fields can be nested. For example, "city" could itself be an object containing "previous_cities" and "current_city". Rows are therefore not tied to a single tabular structure. The disadvantage is that the structure is usually somewhat heavier to read visually compared to CSV. There is also more syntax involved, making it somewhat more difficult to process manually without the help of a library.


Processing JSON Files with the Jackson Library

JSON can of course be processed manually by splitting strings, but in practice this approach is error-prone. Therefore, JSON is usually processed using a dedicated library. One of the most common Java libraries for this purpose is Jackson.

Add the following dependency to your pom.xml file:

<dependency>
    <groupId>tools.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>3.0.4</version>
</dependency>

After adding the dependency, refresh the Maven project. The following example reads the file people.json into a list of Person objects. We will explain the code in more detail afterward.

import tools.jackson.core.JacksonException;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;

import java.nio.file.Path;
import java.util.List;

public class ReadJson {
    public static void main(String[] args) {
        ObjectMapper mapper = new ObjectMapper();
        Path path = Path.of("data", "people.json");

        try {
            List<Person> people = mapper.readValue(
                    path.toFile(),
                    new TypeReference<List<Person>>() {}
            );

            people.forEach(p ->
                    IO.println(p.name() + " (" + p.age() + "), " + p.city())
            );

        } catch (JacksonException je) {
            IO.println("Failed to read JSON: " + je.getMessage());
        }
    }
}

Reading JSON from a file: Reading begins by creating an ObjectMapper object, which is the Jackson library's main tool for converting JSON into Java objects and vice versa. The readValue method takes a JSON file and information about the type into which the JSON should be converted.

In order for the conversion to be possible, we must model the JSON data using Java objects. Let's create a Java class Person with fields name, age, and city, matching the fields in the JSON file. We also create matching getters and setters as well as a no-argument constructor. A class of this form is required by Jackson so that it can create objects from JSON.

public class Person {
    private String name;
    private int age;
    private String city;

    public Person() {
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public void setCity(String city) {
        this.city = city;
    }

    public String getCity() {
        return city;
    }
}

Reading the file may fail, so the call to readValue() should be wrapped in a try-catch structure. The Jackson library throws a JacksonException, which is a subclass of IOException.

Writing JSON to a file is slightly easier than reading it. Writing is performed with the writeValue method, which accepts a file and an object to be stored, and converts the object into JSON format. In this situation, two different things may fail:

  1. Creating the directory may fail; createDirectories throws an IOException.
  2. JSON processing may fail if the file cannot be written, the JSON is invalid, or the type conversion fails; writeValue throws a JacksonException.

Both exceptions should be handled separately.

The following example writes a list of people to the file data/people-new.json:

import tools.jackson.databind.ObjectMapper;
import tools.jackson.core.JacksonException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;

public class WriteJson {
    static void main() {
        ObjectMapper mapper = new ObjectMapper();

        List<Person> people = List.of(
                new Person("Aino", 22, "Turku"),
                new Person("Pekka", 41, "Oulu")
        );

        Path path = Path.of("data", "people-new.json");

        try {
            Files.createDirectories(path.getParent());
            mapper.writeValue(path.toFile(), people);
            IO.println("JSON written to: " + path.toAbsolutePath());
        } catch (IOException e) {
            IO.println("Failed to create directory: " + e.getMessage());
        } catch (JacksonException je) {
            IO.println("JSON processing failed: " + je.getMessage());
        }
    }
}

Record Classes for Modeling JSON Data

Let's briefly introduce Java's record feature. A Java record is a special kind of class designed for modeling small, straightforward data structures such as structured JSON data. When you define a record, Java automatically generates a number of common routines for you. You automatically get:

  • a constructor that accepts all fields as arguments,
  • accessors corresponding to the fields, whose names are simply the field names themselves (for example, name instead of getName),
  • implementations of equals and hashCode,
  • a useful toString implementation.

The components (attributes) of a record are effectively final fields, meaning that records are largely immutable objects.

For this reason, records are a natural match for JSON libraries. A JSON object often corresponds directly to a single "bundle of data" that can be modeled with a record without any additional boilerplate code. Whereas a traditional class typically requires fields, constructors, and getters to be written separately, a record expresses the same idea in a single line.

It is worth noting that methods and validation logic can still be added to a record. However, the primary purpose of a record is to remain small and focused on representing data. If a class begins to accumulate complex behavior or large amounts of mutable state, a regular class is usually a better choice.

Let's define a data type using a record:

public record Person(String name, int age, String city) {}

In this way, the relatively long Person class can be replaced by a single line. In this particular case, the record provides essentially the same functionality as the traditional class defined earlier.


Exercises

Exercise 6.9: Words 1 p.

Download the dataset: words.txt

Save the file in your project's working directory as words.txt.

The file contains one word per line. It intentionally contains empty lines, leading/trailing whitespace, duplicate words, and different letter cases (e.g. Java, java, JAVA).

Example from the beginning of the dataset:

  Java
python

JAVA
CSharp
  java

Create a program that:

  • Reads all lines.
  • Removes extra whitespace from the words and converts them to lowercase. Hint: String.trim() and String.toLowerCase().
  • Removes empty lines.
  • Removes duplicates. (Hint: the distinct() method in the Stream API, or a Set collection.)
  • Sorts the words alphabetically. (Hint: the sorted() method in the Stream API, or the Collections.sort() method on a List.)
  • Writes a new file output/words-clean.txt containing the cleaned word list (one word per line).
  • Also writes a file output/report.txt containing:
    • the number of original lines
    • the number of unique words after removing empty lines and cleaning the words
    • the longest word (if there are several, any one of them is acceptable)

Hint: If you solve the task using the Stream API, you can use the Stream.max(Comparator) method to find the longest word.

Hint: First create List<String> cleaned = ..., and then use Files.write(...) to write the results to the two output files.

report.txt should look like this:

Original lines: 1074
Cleaned words: 59
Longest word: binarytree
Complete this exercise in TIM
Exercise 6.10: Load people from a JSON file 1 p.
  • Create a new Maven project that uses the Jackson library for processing JSON files.
  • Add the required dependency to your pom.xml file.
  • Download people.json and save it in the same folder as your code.
  • Create a Person class or an equivalent record with the fields String name, int age, and String city.
  • Read the file people.json and convert it into a list of Person objects.
  • Filter the list so that only people aged 18 or older are included.
  • Print their name, age, and city.

Remember to save people.json in the same folder as your code. Then check your run configuration and make sure that the working directory is the same as the folder containing your code so that the file can be found.

You can access the contents of people.json here if necessary:

people.json
[
  { "name": "Maija Laine", "age": 25, "city": "Jyväskylä" },
  { "name": "Matti Virtanen", "age": 30, "city": "Tampere" },
  { "name": "Liisa Niemi", "age": 17, "city": "Helsinki" },
  { "name": "Pekka Korhonen", "age": 41, "city": "Oulu" },
  { "name": "Aino Salmi", "age": 22, "city": "Turku" },
  { "name": "Jari Heikkinen", "age": 19, "city": "Kuopio" },
  { "name": "Sari Lehto", "age": 16, "city": "Lahti" },
  { "name": "Oskari Mäkinen", "age": 28, "city": "Espoo" },
  { "name": "Emilia Ranta", "age": 33, "city": "Vantaa" },
  { "name": "Teemu Koski", "age": 45, "city": "Pori" },
  { "name": "Noora Aalto", "age": 18, "city": "Joensuu" },
  { "name": "Kalle Hämäläinen", "age": 52, "city": "Rovaniemi" }
]
Complete this exercise in TIM
Exercise 6.11: CSV->JSON 1 p.

Create a program that reads the file people.csv (format: name,age,city) and writes a similar JSON file people.json as the one provided in the previous exercise. If a row is invalid (for example, the age is not a number), skip the row and continue processing.

The output file should look like this. It does not matter if the indentation or line breaks are not exactly the same.

[
  {
    "name": "Maija Laine",
    "age": 25,
    "city": "Jyväskylä"
  },
  {
    "name": "Matti Virtanen",
    "age": 30,
    "city": "Tampere"
  },
  ...
]
Complete this exercise in TIM
Exercise 6.12: Better Calculator 1 p.

Create a simple calculator program that repeatedly asks the user for two numbers and an arithmetic operation, and then prints the result.

The program should work approximately as follows:

Enter an expression in the format <number> <operator> <number>.
Type "exit" to close the program.

> 1 + 1
2.0
> 10 - 1
9.0
> 0.5 * 100
50.0
> 10 / 2
5.0
> 10
Enter an expression in the format <number> <operator> <number>.
> cat
Enter an expression in the format <number> <operator> <number>.
> exit
Program closing.

The program must handle invalid user input so that it does not crash due to incorrect input.

Implement the basic arithmetic operations: addition (+), subtraction (-), multiplication (*), and division (/). In addition, invent and implement at least two additional operations of your own choice.

Do not use conditional statements to select the arithmetic operation itself. However, you may use conditional statements and try/catch blocks to validate user input.

Hint 1

You may implement the operations as lambda expressions. Use either BiFunction<Double, Double, Double> (JavaDoc) or DoubleBinaryOperator (JavaDoc) as the type for the lambda expressions.

Hint 2

You may use the Scanner class to read the user's input:

Scanner reader = new Scanner(userInput);
double number1 = reader.nextDouble();
String operation = reader.next();
double number2 = reader.nextDouble();

You may need to add the necessary exception handling.

Complete this exercise in TIM