Java File Handling: Reading, Writing, and Managing Files

Learn how to read from and write to files, work with CSV and properties files, traverse directories, and perform file operations like copying and moving — all using modern Java APIs.

Why File Handling Matters

Programs that can only work with data in memory lose everything when they stop running. File handling gives your programs persistence — the ability to save data to disk and read it back later. Common use cases include:

  • Configuration files — reading settings when your application starts
  • Log files — recording what your program did (covered in detail in the Java Logging tutorial)
  • Data import/export — reading CSV files from spreadsheets, generating reports
  • Processing text — analyzing logs, transforming data files, batch processing

Java has two file I/O APIs:

  • java.io — The original API (Java 1.0). Classes like File , FileReader , BufferedReader . You'll encounter these in older code.
  • java.nio.file — The modern API (Java 7+). Classes like Path , Files . Cleaner, more capable, and the one you should use in new code.

About This Tutorial

This tutorial focuses on the modern java.nio.file API for new code. We briefly show the classic java.io approach where it helps you understand what's happening underneath, but every recommendation points to the newer API. We cover text files only — binary file handling (images, audio, serialized objects) requires different classes and is a separate topic.

Path vs. File: Understanding the Difference

Before reading or writing anything, you need to represent the file's location. Java has two classes for this:

PathVsFile.java
import java.io.File;
import java.nio.file.Path;
import java.nio.file.Paths;

public class PathVsFile {

    public static void main(String[] args) {

        // OLD way (java.io.File)
        File file = new File("data/employees.txt");

        // NEW way (java.nio.file.Path)
        Path path = Paths.get("data", "employees.txt");

        // Java 11+ shortcut — equivalent to Paths.get()
        Path path2 = Path.of("data", "employees.txt");

        System.out.println("File:  " + file.getAbsolutePath());
        System.out.println("Path:  " + path.toAbsolutePath());
        System.out.println("Path2: " + path2.toAbsolutePath());

        // You can convert between them when needed
        Path fromFile = file.toPath();      // File → Path
        File fromPath = path.toFile();      // Path → File
    }
}
Console Output
File: /home/user/project/data/employees.txt Path: /home/user/project/data/employees.txt Path2: /home/user/project/data/employees.txt

Key differences:

  • Path uses Paths.get("data", "employees.txt") with multiple string arguments — it joins them with the correct separator ( / on Linux/Mac, \ on Windows). With File , you'd have to manually write "data" + File.separator + "employees.txt" .
  • Path is an interface (implemented by the JVM). File is a class with methods that both represent a path and perform operations. Path separates these concerns — the path itself is just a path, and operations go through the Files utility class.
  • Path has useful methods like getParent() , getFileName() , resolve() , and normalize() that make path manipulation cleaner.

Use Forward Slashes in Java Strings

Even on Windows, you can use forward slashes ( / ) in Java path strings: "data/employees.txt" works on every operating system. Windows Java automatically converts them. Avoid backslashes in string literals because \ is an escape character in Java — you'd need to write "data\\employees.txt" , which is harder to read.

The File Class: Checking Files and Directories

Before trying to read or write a file, you often need to check whether it exists, whether it's a file or a directory, or list the contents of a directory. The java.io.File class (or the newer Files methods) handles this:

FileChecks.java
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class FileChecks {

    public static void main(String[] args) {

        Path path = Path.of("data/employees.txt");

        // Modern way — static methods on the Files class
        System.out.println("Exists:   " + Files.exists(path));
        System.out.println("Is file:  " + Files.isRegularFile(path));
        System.out.println("Is dir:   " + Files.isDirectory(path));
        System.out.println("Readable: " + Files.isReadable(path));
        System.out.println("Writable: " + Files.isWritable(path));

        try {
            System.out.println("Size:     " + Files.size(path) + " bytes");
        } catch (IOException e) {
            System.out.println("Size:     (cannot read)");
        }

        // Classic way — instance methods on the File class
        // You'll see this in older codebases:
        File file = path.toFile();
        System.out.println("\n--- Classic File class ---");
        System.out.println("Exists:  " + file.exists());
        System.out.println("Is file: " + file.isFile());
        System.out.println("Is dir:  " + file.isDirectory());
        System.out.println("Length:  " + file.length() + " bytes");
    }
}

For new code, prefer Files.exists() , Files.isRegularFile() , etc. They throw checked exceptions for error cases (like a broken symlink) instead of silently returning false like File.exists() does. This means you're less likely to mistake a permission error for "file doesn't exist."

Reading Text Files

Java provides several ways to read text files, ranging from the classic approach you'll see in legacy code to concise one-liners in modern Java. We cover all of them so you can recognize each style when you encounter it.

Reading All Lines at Once (Java 7+)

For small to medium files (up to a few megabytes), the simplest approach is Files.readAllLines() , which loads the entire file into a List<String> :

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

public class ReadAllLines {

    public static void main(String[] args) {

        // Create a sample file for this example
        Path path = Path.of("hello.txt");

        try {
            // readAllLines loads the ENTIRE file into memory as a List
            List<String> lines = Files.readAllLines(path);

            System.out.println("File has " + lines.size() + " lines:");
            for (String line : lines) {
                System.out.println("  " + line);
            }

        } catch (IOException e) {
            System.err.println("Could not read file: " + e.getMessage());
        }
    }
}

Assuming hello.txt contains:

hello.txt
Hello, world! This is a text file. It has three lines.

The output is:

Console Output
File has 3 lines: Hello, world! This is a text file. It has three lines.

Don't Use readAllLines() on Large Files

readAllLines() loads the entire file into memory at once. A 1 GB file would consume roughly 2 GB of heap memory (the file content plus the String object overhead). For large files, use Files.lines() (shown next) or BufferedReader — both process one line at a time.

Reading with Streams (Java 8+)

Files.lines() returns a Stream<String> that reads the file lazily — one line at a time. This is ideal for large files and pairs naturally with Java Streams operations:

ReadWithStream.java
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;

public class ReadWithStream {

    public static void main(String[] args) {

        Path path = Path.of("hello.txt");

        // Files.lines() returns a Stream — must be closed when done
        try (Stream<String> stream = Files.lines(path)) {

            // You can use any Stream operations
            long lineCount = stream
                    .filter(line -> !line.isBlank())     // skip empty lines
                    .peek(System.out::println)           // print each line
                    .count();                             // count non-empty lines

            System.out.println("Non-empty lines: " + lineCount);

        } catch (IOException e) {
            System.err.println("Could not read file: " + e.getMessage());
        }
    }
}
Console Output
Hello, world! This is a text file. It has three lines. Non-empty lines: 3

This approach uses constant memory regardless of file size — only one line is in memory at a time. The try-with-resources is important because the stream holds an open file handle.

Reading Line by Line with BufferedReader (Classic)

This is the traditional approach you'll see in codebases written before Java 8. It's still perfectly valid and gives you the most control over the reading process:

ReadWithBufferedReader.java
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class ReadWithBufferedReader {

    public static void main(String[] args) {

        // Classic pattern: FileReader wrapped in BufferedReader
        // BufferedReader provides readLine() — FileReader alone does not
        try (BufferedReader reader = new BufferedReader(
                new FileReader("hello.txt"))) {

            String line;
            int lineNumber = 0;

            // readLine() returns null when end of file is reached
            while ((line = reader.readLine()) != null) {
                lineNumber++;
                System.out.println(lineNumber + ": " + line);
            }

        } catch (IOException e) {
            System.err.println("Could not read file: " + e.getMessage());
        }
    }
}
Console Output
1: Hello, world! 2: This is a text file. 3: It has three lines.

Why BufferedReader?

FileReader reads one character at a time from the disk, which is slow. BufferedReader wraps around it and reads a large chunk (typically 8 KB) into memory at once, then serves characters from that buffer. The difference is dramatic — reading a file character-by-character through FileReader might take 100x longer than reading through BufferedReader . Always wrap file readers in BufferedReader if you're using the classic API.

Reading an Entire File as a Single String (Java 11+)

If you need the entire file content as one string (common for small config files or templates), Java 11 added Files.readString() :

ReadAsString.java
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class ReadAsString {

    public static void main(String[] args) {

        try {
            String content = Files.readString(Path.of("hello.txt"));
            System.out.println("File content (" + content.length() + " chars):");
            System.out.println(content);
        } catch (IOException e) {
            System.err.println("Could not read file: " + e.getMessage());
        }
    }
}

Same Size Warning

Files.readString() loads the entire file into memory as a single String . Use it only for files you're confident are small (a few KB at most). For anything larger, use Files.lines() or BufferedReader .

Writing Text Files

Writing follows a similar pattern: you have classic and modern options, and you must always close the writer when done.

Writing with Files.write() (Java 7+)

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

public class WriteWithFiles {

    public static void main(String[] args) throws IOException {

        Path path = Path.of("output.txt");

        List<String> lines = List.of(
                "First line",
                "Second line",
                "Third line"
        );

        // Write all lines — creates the file if it doesn't exist,
        // OVERWRITES it if it does exist
        Files.write(path, lines);

        System.out.println("Wrote " + lines.size() + " lines to " + path);

        // To APPEND instead of overwrite, use StandardOpenOption:
        import java.nio.file.StandardOpenOption;
        Files.write(path, List.of("Appended line"),
                StandardOpenOption.APPEND);
    }
}

Writing with Files.writeString() (Java 11+)

WriteString.java
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;

public class WriteString {

    public static void main(String[] args) throws IOException {

        Path path = Path.of("greeting.txt");

        // Write a single string (overwrite)
        Files.writeString(path, "Hello, world!\nThis is a file.\n");

        // Append more text
        Files.writeString(path, "Appended content.\n",
                StandardOpenOption.APPEND);

        System.out.println("File written successfully.");
    }
}

Writing with BufferedWriter (Classic)

When you need to build the file content incrementally (inside loops, conditional logic, etc.), BufferedWriter gives you line-by-line control:

WriteWithBufferedWriter.java
import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;

public class WriteWithBufferedWriter {

    public static void main(String[] args) {

        Path path = Path.of("numbers.txt");

        // FileWriter wrapped in BufferedWriter for performance
        // The 'true' argument means APPEND mode; 'false' or omitted means OVERWRITE
        try (BufferedWriter writer = new BufferedWriter(
                new FileWriter(path.toFile()))) {

            for (int i = 1; i <= 10; i++) {
                writer.write("Line " + i + ": " + (i * i));
                writer.newLine();  // writes the platform-specific line separator
            }

            System.out.println("Wrote 10 lines to " + path);

        } catch (IOException e) {
            System.err.println("Could not write file: " + e.getMessage());
        }
    }
}
numbers.txt
Line 1: 1 Line 2: 4 Line 3: 9 Line 4: 16 Line 5: 25 Line 6: 36 Line 7: 49 Line 8: 64 Line 9: 81 Line 10: 100

Use newLine() Instead of "\n"

writer.newLine() writes the correct line separator for the current operating system ( \n on Linux/Mac, \r\n on Windows). If you hardcode "\n" , files created on Linux will look wrong when opened in some Windows editors (all on one line). For files that stay on one machine this rarely matters, but newLine() is the correct practice for portable code.

Character Encoding: Why It Matters

Text files are stored as bytes. The character encoding (charset) defines how those bytes map to characters. If you read a file with the wrong encoding, you get garbled text (mojibake). The most common encodings:

  • UTF-8 — The modern standard. Supports every character in every language. This is what you should use for virtually everything.
  • ISO-8859-1 (Latin-1) — Old Western European encoding. Cannot represent characters outside Western European languages.
  • Windows-1252 — Microsoft's extension of Latin-1. Still common in older Windows systems.
  • UTF-16 — Used internally by Java for char and String . Rarely used for files.

The problem: FileReader and FileWriter use the platform default encoding , which varies by operating system. On most modern systems it's UTF-8, but on some Windows configurations it's Windows-1252. This means the same code can produce different results on different machines.

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

public class EncodingDemo {

    public static void main(String[] args) throws IOException {

        Path path = Path.of("greeting.txt");

        // BAD — uses platform default encoding (unpredictable)
        String content1 = Files.readString(path);

        // GOOD — explicitly specifies UTF-8 (predictable, works everywhere)
        String content2 = Files.readString(path, StandardCharsets.UTF_8);

        // Writing with explicit encoding
        Files.writeString(
                Path.of("output-utf8.txt"),
                "Héllo, wörld! 你好世界! ",
                StandardCharsets.UTF_8
        );

        // Reading from a file that's in Windows-1252 encoding
        String windowsContent = Files.readString(
                Path.of("legacy-data.txt"),
                java.nio.charset.Charset.forName("Windows-1252")
        );

        // The modern Files methods accept a Charset parameter
        Files.write(
                Path.of("output.txt"),
                List.of("Line 1", "Line 2"),
                StandardCharsets.UTF_8
        );
    }
}

Rule: Always Specify the Encoding

When reading or writing text files, always pass an explicit charset — typically StandardCharsets.UTF_8 . Never rely on the platform default. This is especially important for files that might be transferred between systems (Linux servers, Windows workstations, cloud storage). A file written with UTF-8 on Linux and read with Windows-1252 on Windows will corrupt any non-ASCII characters.

Ad placeholder
Advertisement

Working with CSV Files

CSV (Comma-Separated Values) is one of the most common file formats for data exchange. Despite its simplicity, there are edge cases you need to handle: fields containing commas, fields containing quotes, and empty fields.

Here's a robust CSV reader that handles quoted fields correctly, followed by a writer:

CsvReader.java
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

/**
 * A simple CSV parser that handles:
 *   - Quoted fields: "Smith, John" → Smith, John
 *   - Embedded quotes: "He said ""hello""" → He said "hello"
 *   - Empty fields: a,,b → ["a", "", "b"]
 */
public class CsvReader {

    public static List<String[]> read(Path path) throws IOException {
        List<String[]> rows = new ArrayList<>();

        try (var reader = Files.newBufferedReader(path)) {
            String line;
            while ((line = reader.readLine()) != null) {
                rows.add(parseLine(line));
            }
        }
        return rows;
    }

    private static String[] parseLine(String line) {
        List<String> fields = new ArrayList<>();
        StringBuilder field = new StringBuilder();
        boolean inQuotes = false;

        for (int i = 0; i < line.length(); i++) {
            char c = line.charAt(i);

            if (inQuotes) {
                if (c == '"') {
                    // Two consecutive quotes = one literal quote inside a field
                    if (i + 1 < line.length() && line.charAt(i + 1) == '"') {
                        field.append('"');
                        i++;  // skip the second quote
                    } else {
                        inQuotes = false;  // closing quote
                    }
                } else {
                    field.append(c);
                }
            } else {
                if (c == '"') {
                    inQuotes = true;
                } else if (c == ',') {
                    fields.add(field.toString());
                    field = new StringBuilder();
                } else {
                    field.append(c);
                }
            }
        }
        fields.add(field.toString());  // don't forget the last field

        return fields.toArray(new String[0]);
    }
}
CsvWriter.java
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
import java.util.List;

public class CsvWriter {

    /**
     * Writes a list of rows to a CSV file.
     * Fields containing commas or quotes are automatically quoted.
     */
    public static void write(Path path, List<String[]> rows) throws IOException {
        try (BufferedWriter writer = Files.newBufferedWriter(
                path, StandardCharsets.UTF_8)) {

            for (String[] row : rows) {
                for (int i = 0; i < row.length; i++) {
                    if (i > 0) {
                        writer.write(',');
                    }
                    writer.write(escapeField(row[i]));
                }
                writer.newLine();
            }
        }
    }

    /**
     * If the field contains a comma, quote, or newline, wrap it in quotes
     * and double any internal quotes.
     */
    private static String escapeField(String field) {
        if (field.contains(",") || field.contains("\"") || field.contains("\n")) {
            return "\"" + field.replace("\"", "\"\"") + "\"";
        }
        return field;
    }
}

Let's test them together:

CsvDemo.java
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;

public class CsvDemo {

    public static void main(String[] args) throws IOException {

        Path csvPath = Path.of("employees.csv");

        // Write a CSV with tricky fields
        List<String[]> data = List.of(
            new String[]{"Name", "Department", "Notes"},
            new String[]{"Alice Johnson", "Engineering", "Senior developer"},
            new String[]{"Smith, Bob", "Marketing", "Name has a comma"},
            new String[]{"Carol Davis", "Sales", "Said \"hello world\" at the meeting"}
        );

        CsvWriter.write(csvPath, data);
        System.out.println("Wrote CSV file. Raw content:");
        System.out.println(Files.readString(csvPath));

        // Read it back
        List<String[]> rows = CsvReader.read(csvPath);
        System.out.println("Parsed back:");
        for (String[] row : rows) {
            System.out.println("  " + Arrays.toString(row));
        }
    }
}
Console Output
Wrote CSV file. Raw content: Name,Department,Notes Alice Johnson,Engineering,Senior developer "Smith, Bob",Marketing,Name has a comma Carol Davis,Sales,"Said ""hello world"" at the meeting" Parsed back: [Name, Department, Notes] [Alice Johnson, Engineering, Senior developer] [Smith, Bob, Marketing, Name has a comma] [Carol Davis, Sales, Said "hello world" at the meeting]

Notice how the raw CSV file correctly quotes "Smith, Bob" and escapes the internal quotes in "Said ""hello world"" at the meeting" . The parser reads them back exactly as the original strings. This is the correct CSV behavior defined by RFC 4180 .

When to Use a Library Instead

The CSV reader/writer above handles the most common cases. For production applications with complex requirements (different delimiters, null handling, type conversion, very large files), consider using a library like OpenCSV or Apache Commons CSV . They handle edge cases we haven't covered here (like multiline quoted fields) and provide a cleaner API for mapping CSV rows directly to Java objects.

Working with Properties Files

Java has built-in support for .properties files — simple key-value configuration files widely used in Java applications. You've already seen them if you followed the Java Logging tutorial.

config.properties
# Application Configuration
# Lines starting with # are comments
app.name=My Application
app.version=2.1.0
db.host=localhost
db.port=3306
db.name=company_db
max.connections=10
debug.mode=false
PropertiesDemo.java
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Properties;

public class PropertiesDemo {

    public static void main(String[] args) {

        Properties props = new Properties();

        // --- READING ---
        try (FileInputStream input = new FileInputStream("config.properties")) {
            props.load(input);
        } catch (IOException e) {
            System.err.println("Could not load config: " + e.getMessage());
            return;
        }

        // Retrieve values — all values are Strings
        String appName = props.getProperty("app.name");
        String dbHost = props.getProperty("db.host");
        String dbPort = props.getProperty("db.port", "3306");  // default value
        String missing = props.getProperty("nonexistent", "default");

        System.out.println("App: " + appName);
        System.out.println("Database: " + dbHost + ":" + dbPort);
        System.out.println("Missing key: " + missing);

        // Type conversion — Properties only stores Strings
        int maxConn = Integer.parseInt(props.getProperty("max.connections"));
        boolean debug = Boolean.parseBoolean(props.getProperty("debug.mode"));
        System.out.println("Max connections: " + maxConn);
        System.out.println("Debug mode: " + debug);

        // --- WRITING ---
        // Modify a value
        props.setProperty("app.version", "2.2.0");
        props.setProperty("last.updated", java.time.LocalDate.now().toString());

        try (FileOutputStream output = new FileOutputStream("config.properties")) {
            props.store(output, "Updated by PropertiesDemo");
            System.out.println("\nConfig file updated.");
        } catch (IOException e) {
            System.err.println("Could not save config: " + e.getMessage());
        }
    }
}
Console Output
App: My Application Database: localhost:3306 Missing key: default Max connections: 10 Debug mode: false Config file updated.

Properties Files Are String-Only

The Properties class stores everything as String values. Even if you write max.connections=10 , calling props.get("max.connections") returns the String "10" , not the Integer 10 . You must convert types yourself with Integer.parseInt() , Boolean.parseBoolean() , etc. The getProperty() method returns String directly; get() returns Object and requires casting.

Creating and Traversing Directories

Creating Directories

DirectoryOps.java
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class DirectoryOps {

    public static void main(String[] args) throws IOException {

        // createDirectory() — creates ONE level. Fails if parent doesn't exist.
        Path single = Path.of("output");
        if (!Files.exists(single)) {
            Files.createDirectory(single);
            System.out.println("Created: " + single);
        }

        // createDirectories() — creates ALL missing parent directories.
        // This is the one you'll use most often.
        Path nested = Path.of("output/reports/2025/january");
        Files.createDirectories(nested);
        System.out.println("Created: " + nested);

        // If the directory already exists, createDirectories() does nothing — no error.
        Files.createDirectories(nested);  // safe to call again
        System.out.println("Called createDirectories again — no error.");
    }
}
Console Output
Created: output Created: output/reports/2025/january Called createDirectories again — no error.

Always Use createDirectories()

createDirectory() fails with NoSuchFileException if the parent directory doesn't exist. createDirectories() creates the entire path and is idempotent (safe to call multiple times). In practice, you should almost always use createDirectories() .

Listing Directory Contents

ListDirectory.java
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.stream.Stream;

public class ListDirectory {

    public static void main(String[] args) {

        Path dir = Path.of(".");  // current directory

        // List IMMEDIATE children only (non-recursive)
        try (Stream<Path> stream = Files.list(dir)) {
            stream.forEach(path -> {
                try {
                    if (Files.isDirectory(path)) {
                        System.out.printf("  [DIR]  %s%n", path.getFileName());
                    } else {
                        long size = Files.size(path);
                        System.out.printf("  [FILE] %-25s %8d bytes%n",
                                path.getFileName(), size);
                    }
                } catch (IOException e) {
                    System.out.printf("  [????] %s (error: %s)%n",
                            path.getFileName(), e.getMessage());
                }
            });
        } catch (IOException e) {
            System.err.println("Could not list directory: " + e.getMessage());
        }
    }
}
Console Output
[DIR] output [DIR] src [FILE] config.properties 185 bytes [FILE] hello.txt 52 bytes [FILE] numbers.txt 89 bytes

Walking a Directory Tree (Recursive)

Files.walk() traverses a directory and all its subdirectories recursively. This is useful for searching, counting, or processing files across an entire project:

WalkDirectory.java
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;

public class WalkDirectory {

    public static void main(String[] args) {

        Path start = Path.of("src");

        // walk() visits every file and directory recursively
        // The maxDepth parameter (optional) limits how deep to go
        try (Stream<Path> stream = Files.walk(start)) {

            // Find all .java files and print their sizes
            long javaCount = stream
                    .filter(Files::isRegularFile)
                    .filter(p -> p.toString().endsWith(".java"))
                    .peek(p -> {
                        try {
                            System.out.printf("  %s (%d bytes)%n",
                                    start.relativize(p), Files.size(p));
                        } catch (IOException e) {
                            System.out.println("  " + start.relativize(p) + " (error)");
                        }
                    })
                    .count();

            System.out.println("\nTotal .java files: " + javaCount);

        } catch (IOException e) {
            System.err.println("Error walking directory: " + e.getMessage());
        }
    }
}
Console Output
main/ListDirectory.java (842 bytes) main/WalkDirectory.java (731 bytes) main/PropertiesDemo.java (1056 bytes) main/CsvDemo.java (623 bytes) Total .java files: 4

Symbolic Link Loops

Files.walk() follows symbolic links by default. If a directory contains a symlink that points to a parent directory (a cycle), walk() will loop forever. Use Files.walk(start, FileVisitOption.FOLLOW_LINKS) only when you specifically need it, and prefer the default behavior (no link following) for safety. Alternatively, use Files.walkFileTree() with a custom FileVisitor for more control.

Copying, Moving, and Deleting Files

The Files class provides one-line methods for common file operations:

FileOperations.java
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.StandardCopyOption;

public class FileOperations {

    public static void main(String[] args) throws IOException {

        // Ensure source file exists
        Files.writeString(Path.of("original.txt"), "This is the original file.\n");

        // --- COPY ---
        Path copy = Path.of("backup/original-copy.txt");
        Files.createDirectories(copy.getParent());  // ensure parent exists

        // REPLACE_EXISTING overwrites if the target already exists
        Files.copy(Path.of("original.txt"), copy,
                StandardCopyOption.REPLACE_EXISTING);
        System.out.println("Copied to: " + copy);

        // --- MOVE (rename) ---
        Path moved = Path.of("backup/original-moved.txt");
        Files.move(copy, moved, StandardCopyOption.REPLACE_EXISTING);
        System.out.println("Moved to: " + moved);

        // The original copy no longer exists
        System.out.println("Copy still exists? " + Files.exists(copy));

        // --- DELETE ---
        boolean deleted = Files.deleteIfExists(moved);
        System.out.println("Deleted moved file? " + deleted);

        // deleteIfExists() returns false if the file didn't exist (no exception)
        // delete() throws NoSuchFileException if the file doesn't exist

        // --- DELETE a non-empty directory ---
        // Files.delete() CANNOT delete a non-empty directory.
        // You need to walk the tree and delete files first:
        Path dirToDelete = Path.of("backup");
        try (var entries = Files.walk(dirToDelete)) {
            entries.sorted(java.util.Comparator.reverseOrder())  // delete files before dirs
                   .forEach(path -> {
                       try {
                           Files.deleteIfExists(path);
                       } catch (IOException e) {
                           System.err.println("Could not delete: " + path);
                       }
                   });
        }
        System.out.println("Deleted directory tree.");
    }
}
Console Output
Copied to: backup/original-copy.txt Moved to: backup/original-moved.txt Copy still exists? false Deleted moved file? true Deleted directory tree.

Deleting Non-Empty Directories

Files.delete() and Files.deleteIfExists() throw DirectoryNotEmptyException if the target is a directory that contains files. You must delete the contents first. The walk().sorted(Comparator.reverseOrder()) pattern shown above ensures files are deleted before their parent directories. Alternatively, use Files.walkFileTree() with a SimpleFileVisitor that overrides postVisitDirectory() for more robust handling.

Real-World Example: Log File Analyzer

Here's a practical example that combines reading, parsing, and analyzing a text file — a simple log analyzer that counts HTTP status codes from a server log file:

server.log (sample input)
2025-07-15 10:01:23 GET /index.html 200 1024
2025-07-15 10:01:24 GET /style.css 200 4096
2025-07-15 10:01:25 GET /logo.png 200 15360
2025-07-15 10:01:26 GET /nonexistent 404 256
2025-07-15 10:01:27 POST /api/login 401 128
2025-07-15 10:01:28 GET /index.html 200 1024
2025-07-15 10:01:29 GET /api/users 200 8192
2025-07-15 10:01:30 GET /old-page 301 0
2025-07-15 10:01:31 POST /api/upload 500 64
2025-07-15 10:01:32 GET /about.html 200 2048
2025-07-15 10:01:33 GET /favicon.ico 404 128
2025-07-15 10:01:34 GET /api/data 200 32768
LogAnalyzer.java
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Stream;

public class LogAnalyzer {

    public static void main(String[] args) {

        Path logFile = Path.of("server.log");
        Path reportFile = Path.of("analysis-report.txt");

        if (!Files.exists(logFile)) {
            System.err.println("Log file not found: " + logFile);
            return;
        }

        // Counters
        Map<String, Integer> statusCounts = new TreeMap<>();
        Map<String, Integer> endpointCounts = new TreeMap<>();
        long totalBytes = 0;
        long totalRequests = 0;

        // Process the log file line by line (constant memory)
        try (Stream<String> lines = Files.lines(logFile)) {

            totalRequests = lines
                    .filter(line -> !line.isBlank())
                    .peek(line -> {
                        String[] parts = line.split("\\s+");
                        if (parts.length >= 5) {
                            String method = parts[2];
                            String endpoint = method + " " + parts[3];
                            String status = parts[4];

                            // Count status codes
                            statusCounts.merge(status, 1, Integer::sum);

                            // Count endpoints
                            endpointCounts.merge(endpoint, 1, Integer::sum);

                            // Sum response sizes
                            try {
                                totalBytes += Long.parseLong(parts[5]);
                            } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
                                // skip malformed lines
                            }
                        }
                    })
                    .count();

        } catch (IOException e) {
            System.err.println("Error reading log: " + e.getMessage());
            return;
        }

        // Build and write the report
        StringBuilder report = new StringBuilder();
        report.append("=== Log Analysis Report ===\n\n");
        report.append("Total requests: ").append(totalRequests).append("\n");
        report.append("Total bytes:   ").append(totalBytes).append("\n\n");

        report.append("--- Status Code Distribution ---\n");
        statusCounts.forEach((status, count) ->
                report.append(String.format("  %-6s %d (%.1f%%)%n",
                        status, count, 100.0 * count / totalRequests))
        );

        report.append("\n--- Top Endpoints ---\n");
        endpointCounts.entrySet().stream()
                .sorted(Map.Entry.comparingByValue().reversed())
                .forEach(entry ->
                        report.append(String.format("  %-30s %d hits%n",
                                entry.getKey(), entry.getValue()))
                );

        // Write the report to a file
        Files.createDirectories(reportFile.getParent());
        Files.writeString(reportFile, report.toString());

        // Also print to console
        System.out.println(report);
        System.out.println("Report written to: " + reportFile.toAbsolutePath());
    }
}
Console Output
=== Log Analysis Report === Total requests: 12 Total bytes: 63360 --- Status Code Distribution --- 200 8 (66.7%) 301 1 (8.3%) 401 1 (8.3%) 404 2 (16.7%) 500 1 (8.3%) --- Top Endpoints --- GET /index.html 2 hits GET /api/data 1 hits GET /api/users 1 hits GET /about.html 1 hits GET /favicon.ico 1 hits GET /logo.png 1 hits GET /nonexistent 1 hits GET /old-page 1 hits GET /style.css 1 hits POST /api/login 1 hits POST /api/upload 1 hits Report written to: /home/user/project/analysis-report.txt

This example demonstrates several real-world techniques:

  • Files.lines() for constant-memory processing of a potentially large file
  • Defensive parsing with parts.length >= 5 and a try-catch for malformed lines — real log files often have anomalies
  • Map.merge() for concise counter incrementing
  • Combining stream operations ( filter , peek , count , sorted ) in a pipeline
  • Writing the report to both a file and the console
Ad placeholder
Advertisement

Best Practices

Resource Management

  • Always close file handles. Use try-with-resources for BufferedReader , BufferedWriter , Stream<String> from Files.lines() , and any other resource that wraps an open file. An unclosed file handle is a file handle leak — the OS limits how many files a process can have open, and when you hit that limit, your program (and possibly other programs on the same machine) can't open any more files.
  • The one-liner methods are safe. Files.readAllLines() , Files.writeString() , Files.write() open and close the file internally — you don't need try-with-resources for these.

Path Handling

  • Use Path.of() (Java 11+) or Paths.get() instead of string concatenation with File.separator .
  • Use path.resolve(other) to join paths instead of string concatenation: baseDir.resolve("data").resolve("file.txt") . This handles trailing slashes and absolute paths correctly.
  • Use path.getParent() to get a directory from a file path, and always call Files.createDirectories(parent) before writing — don't assume the directory exists.

Encoding

  • Always specify UTF-8 explicitly when reading or writing text files. Never rely on the platform default encoding.

Error Handling

  • Check Files.exists() before reading if a missing file is an expected condition (like a config file that might not exist yet). If it's unexpected, let the IOException propagate — don't swallow it.
  • Don't silently ignore IOException . At minimum, log it. At best, propagate it to the caller so they can decide what to do.
  • Validate file paths from user input. A malicious path like ../../etc/passwd could read sensitive files. Use path.normalize() and check that the resolved path stays within an allowed directory.

Common Errors and Fixes

Exception Cause Fix
FileNotFoundException File doesn't exist at the given path Check the path; print path.toAbsolutePath() to see where Java is actually looking
NoSuchFileException Same as above, but from the NIO API Same fix; also check that the parent directory exists
AccessDeniedException No read/write permission for the file Check file permissions; on Linux, chmod 644 filename
DirectoryNotEmptyException Tried to delete a non-empty directory Delete contents first (see the delete example above)
FileAlreadyExistsException Tried to create a file/directory that already exists Use createDirectories() (idempotent), or add REPLACE_EXISTING to copy/move
UnsupportedEncodingException Used a charset name the JVM doesn't recognize Use StandardCharsets.UTF_8 instead of string names like "utf-8"

Debugging "File Not Found" Errors

The most common cause of "file not found" is a wrong working directory . When you run java MyClass , the working directory is wherever you ran the command from — not necessarily where your .java file is. Always print Path.of("myfile.txt").toAbsolutePath() to see the full path Java is using. In an IDE, the working directory is usually the project root, but it depends on your run configuration.

Exercise

Exercise: Word Frequency Counter

Build a program that:

  1. Reads a text file specified as the first command-line argument ( args[0] ).
  2. Splits each line into words (split on non-letter characters — use line.split("[^a-zA-Z]+") ).
  3. Converts all words to lowercase.
  4. Counts how many times each word appears using a HashMap<String, Integer> .
  5. Sorts the words by frequency (highest first), then alphabetically for ties.
  6. Writes the results to a file called word-frequencies.txt in the format:
    the: 42
    java: 28
    file: 15
  7. Also prints the top 10 most frequent words to the console.

Requirements:

  • Use Files.lines() for constant-memory reading.
  • Use try-with-resources for the stream.
  • Specify UTF-8 encoding when writing the output.
  • Handle the case where the input file doesn't exist gracefully (print an error message and exit).

Solution

WordFrequencyCounter.java
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class WordFrequencyCounter {

    public static void main(String[] args) {

        if (args.length < 1) {
            System.err.println("Usage: java WordFrequencyCounter <input-file>");
            return;
        }

        Path inputFile = Path.of(args[0]);
        Path outputFile = Path.of("word-frequencies.txt";

        if (!Files.exists(inputFile)) {
            System.err.println("File not found: " + inputFile.toAbsolutePath());
            return;
        }

        // Count word frequencies
        Map<String, Integer> frequencies = new HashMap<>();

        try (Stream<String> lines = Files.lines(inputFile, StandardCharsets.UTF_8)) {
            lines.flatMap(line -> Stream.of(line.split("[^a-zA-Z]+")))
                 .map(String::toLowerCase)
                 .filter(word -> !word.isEmpty())
                 .forEach(word -> frequencies.merge(word, 1, Integer::sum));
        } catch (IOException e) {
            System.err.println("Error reading file: " + e.getMessage());
            return;
        }

        // Sort by frequency (desc), then alphabetically (asc) for ties
        List<Map.Entry<String, Integer>> sorted = frequencies.entrySet().stream()
                .sorted(Map.Entry.<String, Integer>comparingByValue().reversed()
                        .thenComparing(Map.Entry::getKey))
                .collect(Collectors.toList());

        // Print top 10 to console
        System.out.println("Top 10 most frequent words:");
        sorted.stream()
             .limit(10)
             .forEach(e -> System.out.printf("  %-20s %d%n", e.getKey() + ":", e.getValue()));

        // Write all frequencies to file
        String report = sorted.stream()
                .map(e -> e.getKey() + ": " + e.getValue())
                .collect(Collectors.joining("\n"));

        try {
            Files.writeString(outputFile, report, StandardCharsets.UTF_8);
            System.out.println("\nFull report written to: " + outputFile.toAbsolutePath());
            System.out.println("Total unique words: " + sorted.size());
        } catch (IOException e) {
            System.err.println("Error writing report: " + e.getMessage());
        }
    }
}
Console Output (run with: java WordFrequencyCounter hello.txt)
Top 10 most frequent words: a: 1 file: 1 has: 1 hello: 1 is: 1 it: 1 text: 1 this: 1 three: 1 world: 1 Full report written to: /home/user/project/word-frequencies.txt Total unique words: 10
Ad placeholder
Advertisement

Summary

  • Use Path and Files (from java.nio.file ) for all new code. They're cleaner, more capable, and have better error handling than the legacy java.io.File class.
  • For small files: Files.readAllLines() , Files.readString() , Files.write() , Files.writeString() — concise one-liners.
  • For large files: Files.lines() (returns a Stream) or Files.newBufferedReader() — process one line at a time.
  • For line-by-line control: BufferedWriter with Files.newBufferedWriter() .
  • Always specify UTF-8 encoding with StandardCharsets.UTF_8 .
  • Always close resources with try-with-resources — file handle leaks are a real production problem.
  • Use Files.createDirectories() (not createDirectory() ) — it's idempotent and creates parent directories.
  • CSV parsing requires handling quoted fields and escaped quotes. Use a library for production CSV work.
  • Properties files are Java's built-in format for key-value configuration. All values are Strings — convert types manually.

Next Steps

  • Java JDBC — Use file I/O skills to read SQL scripts from files, write query results to CSV reports, and manage database configuration via properties files.
  • Java Logging — Understand how the logging framework writes to files, and how to configure file handlers with rotation.
  • Exception Handling — Deepen your understanding of try-with-resources and exception chaining, which are essential for robust file handling code.