Search⌘ K
AI Features

Reading and Writing Text Files

Explore how to read and write text files in Java efficiently and safely by mastering character streams, buffered I/O, and the try-with-resources pattern. Understand when to use detailed stream handling versus simplified Files utility methods to handle various file sizes and avoid common resource leaks and exceptions during file processing.

Most applications eventually need to interact with the world outside their own memory. Whether we are figuring out how to make a java file, output a user’s configuration, saving a game state, or processing a server log, we need a reliable way to read and write data to the disk.

However, file systems are unpredictable; files might be missing, locked by another process, or protected by permissions. If we do not handle these edge cases correctly, our application will crash or leak system resources. In this lesson, we will master the standard patterns for performing efficient, safe, and robust text I/O in Java.

Understanding streams and buffers

When we read a file, we are essentially pulling a stream of bytes from the disk into our application’s memory. In Java, we distinguish between byte streams (for raw data, such as images) and character streams (for text). Since we are focusing on text, we work with character streams.

Reading data directly from the disk one character at a time is extremely inefficient. The disk drive is the slowest part of a computer, and frequent access to small amounts of data creates a performance bottleneck. To solve this, Java uses buffering.

Instead of reading one character at a time, a buffered reader fetches a large chunk (or “block”) of text from the disk and stores it in a temporary memory area called a buffer. When our code asks for the next line of text, Java retrieves it instantly from this fast memory buffer rather than going back to the slow disk. We only return to the disk when the buffer is empty.

Safety first: The try-with-resources pattern

Before opening files in Java, understand how the runtime and operating system manage file resources. When Java opens a file, the JVM requests a file descriptor (also called a file handle) from the operating system. This descriptor represents the open file resource. The operating system enforces limits on the number of open file descriptors per process and system-wide. If a file is not closed properly, the file descriptor remains allocated until the process or the JVM terminates. This situation is known as a resource leak. If a program exhausts available file descriptors, it can fail with I/O errors and may prevent other processes from opening additional files.

In older versions of Java, developers had to write verbose finally blocks to ensure files were closed, even if errors occurred. Since Java 7, we have a superior standard: the try-with-resources statement.

This structure allows us to declare resources (like streams) inside parentheses after the try keyword. Java guarantees that any resource implementing the AutoCloseable interface will be closed automatically when the block finishes, regardless of whether the code succeeded or failed.

We must also handle the IOException. This is a checked exception that Java throws when an input/output operation fails (e.g., the disk is full or the file does not exist).

Here is the template we will use for all stream-based I/O:

Java 25
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class ResourceSafety {
public static void main(String[] args) {
Path file = Path.of("example.txt");
// The resource is declared inside the try(...) parentheses
try (BufferedReader reader = Files.newBufferedReader(file)) {
// If an error happens here, the reader still closes automatically
System.out.println("File opened successfully!");
} catch (IOException e) {
// We handle the failure gracefully
System.err.println("Failed to perform I/O: " + e.getMessage());
}
}
}
  • Line 8: We define the file location using the Path API.

  • Line 11: We initialize the BufferedReader inside the try parentheses. This ensures the stream is closed automatically at the end of the block.

  • Line 13: We perform our file operations inside the block.

  • Line 14: If Files.newBufferedReader fails (e.g., file not found), execution jumps immediately to the catch block.

Reading text efficiently

Now that we have a safe structure, let’s read a file. The most common tool for this is the BufferedReader. It provides a convenient method, readLine(), which reads text until it encounters a newline character.

We use the factory method Files.newBufferedReader(Path) to create our reader. This is preferred over older constructors because it integrates seamlessly with the modern Path API and handles character encoding (UTF-8) by default.

For this example, assume we have a file named server.log with the following content:

Java 25
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class ReadFileExample {
public static void main(String[] args) {
Path logFile = Path.of("server.log");
try (BufferedReader reader = Files.newBufferedReader(logFile)) {
String line;
// We read lines one by one until readLine returns null (end of file)
while ((line = reader.readLine()) != null) {
System.out.println("Log entry: " + line);
}
} catch (IOException e) {
System.err.println("Error reading log file: " + e.getMessage());
}
}
}
  • Line 10: We create a BufferedReader for server.log inside the resource block.

  • Line 13: The condition (line = reader.readLine()) != null performs two actions: it assigns the next line of text to the variable line, and then checks if that result is null.

  • Line 14: If line is not null, we process the data.

  • Line 16: When the end of the file is reached, readLine() returns null, the loop terminates, and the try-with-resources block closes the stream.

Writing text files

To write text, we use the BufferedWriter. Similar to the reader, this class buffers our output characters and writes them to the disk in efficient blocks. We create it using Files.newBufferedWriter(Path, OpenOption...).

By default, creating a writer for an existing file will overwrite or truncate it. If we want to add data to the end of an existing file (such as, adding a line to a log), we must specify StandardOpenOption.APPEND.

import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        Path report = Path.of("daily_report.txt");
        List<String> data = List.of("Status: OK", "Users: 42", "Errors: 0");

        // We open the file for creating or appending
        try (BufferedWriter writer = Files.newBufferedWriter(report, 
                StandardOpenOption.CREATE, 
                StandardOpenOption.APPEND)) {
            
            for (String line : data) {
                writer.write(line);   // Writes the string to the buffer
                writer.newLine();     // Writes a system-appropriate line separator
            }
            System.out.println("Report appended successfully.");

        } catch (IOException e) {
            System.err.println("Could not write report: " + e.getMessage());
        }
    }
}
Writing multiple lines to a file using BufferedWriter
  • Line 11: List.of creates a simple list of strings to write.

  • Lines 14–16: We initialize the writer. CREATE ensures the file is created if missing; APPEND adds data to the end rather than erasing existing content.

  • Line 19: writer.write(line) puts the text into the buffer.

  • Line 20: writer.newLine() adds a line break (e.g., \n or \r\n) suited to the operating system.

Once we run this application, we can verify that the file was created using the ls command and inspect its content using the cat daily-report.txt command directly in our terminal:

$ ls
daily_report.txt ...
$ cat daily_report.txt
Status: OK
Users: 42
Errors: 0

Simplified I/O for small files

Using streams gives us precise control and high efficiency for large files, but it requires significant boilerplate code. For small files such as configuration files or simple text snippets, Java provides utility methods in the Files class to read or write everything in a single step.

The methods Files.readString(Path) and Files.writeString(Path, CharSequence) handle opening, reading/writing, and closing the resource internally.

Warning: Do not use readString on massive files (e.g., gigabyte-sized logs). It attempts to load the entire file content into memory at once, which will cause an OutOfMemoryError. Use this only when you are certain the file size is manageable.

For this example, assume we have a small configuration file named config.json. Here is how we read and write simplified files:

{
  "theme": "dark",
  "version": 1.4
}
Using Files utility methods for concise I/O on small files
  • Line 12: Files.exists checks if the file is present before we attempt to read it, preventing a NoSuchFileException.

  • Line 13: Files.readString opens the file, reads all bytes, decodes them to a String, and closes the file automatically. If the file is deleted between line 12 and line 13, or if permissions are insufficient, an IOException is thrown and we must still catch it.

  • Line 19: Files.writeString writes the timestamp string to last_run.txt, creating the file if it doesn’t exist or truncating it if it does.

Similar to the previous example, we can check our terminal to see the newly created timestamp file:

$ cat last_run.txt
Last run: 1766147560363

We have now explored two approaches to file I/O. The try-with-resources pattern with BufferedReader and BufferedWriter is your robust, professional tool for handling data of any size. It keeps memory usage low and ensures safety. The Files utility methods are excellent shortcuts for small, simple tasks. By mastering both and knowing when to switch, you ensure your Java applications interact with the file system efficiently and safely.