Skip to content
CalliCoder

How to Read a File in Java

Published Updated Java 13 min read

Files.readString for a small file, Files.lines for a large one, why the stream must be closed, and the charset default that changed in Java 18 and still bites on older runtimes.

There are six ways to read a file in Java and the choice comes down to one question: does the whole file fit comfortably in memory? Everything else, which class, which charset, which exception — follows from that, and the mistake that costs most is reading a large file with a method designed for a small one.

Written against Java 17.

The whole file, when it is small

Path path = Path.of("/var/data/config.json");

String content = Files.readString(path);                          // UTF-8
String other   = Files.readString(path, StandardCharsets.ISO_8859_1);
List<String> lines = Files.readAllLines(path);
byte[] bytes = Files.readAllBytes(path);

Files.readString arrived in Java 11 and is the shortest correct answer for a configuration file, a template or a small document. It defaults to UTF-8 regardless of the platform, which is the important property.

The limit is real: all four throw OutOfMemoryError on a file larger than the heap, and readString additionally throws OutOfMemoryError above 2 GB because a String cannot be longer than Integer.MAX_VALUE. “Small” here means small relative to the heap, and a file whose size is supplied by a user is never small.

Line by line, for anything larger

try (Stream<String> lines = Files.lines(path, StandardCharsets.UTF_8)) {
    lines.filter(line -> !line.isBlank())
         .map(String::trim)
         .forEach(this::process);
}

Files.lines is lazy. It reads as the stream is consumed, so memory is bounded by the longest single line rather than by the size of the file, which makes a multi-gigabyte log tractable.

The try-with-resources is mandatory. The stream holds an open file handle, and Stream does not close itself. Leaking them exhausts the process’s file descriptors, and the failure appears much later as “too many open files” in unrelated code. This is the single most common defect in code using this method, because the version without it works perfectly in a test.

Files.newBufferedReader is the same thing with an explicit loop:

try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
    String line;
    while ((line = reader.readLine()) != null) {
        process(line);
    }
}

Prefer this when the loop needs to break early, keep state across lines, or throw a checked exception — all three are awkward inside a stream, and the last one is genuinely painful.

Note that readLine() strips the line terminator and cannot tell you which one it was, so a round-trip through it normalises CRLF to whatever you write back.

Always name the charset

new FileReader(path.toFile())                        // platform default before Java 18
new String(bytes)                                    // platform default before Java 18
Files.readString(path)                               // always UTF-8
new InputStreamReader(in, StandardCharsets.UTF_8)    // explicit

Java 18 changed the default charset for these APIs to UTF-8. On Java 17 and earlier the default came from the operating system, so the same code read a file correctly on a developer’s machine and mangled every accented character in a container running with POSIX as its locale.

Because plenty of code still runs on 17 and earlier, and because an explicit charset documents intent either way, name it. Files.readString and Files.newBufferedReader already default to UTF-8 on every version, which is a reason to prefer them over FileReader.

A UTF-8 file exported for Excel often begins with a byte order mark, and Java does not strip it: the first character of the first line is . A parser matching on the first column name fails on a file that looks correct in an editor.

Binary files

try (InputStream in = Files.newInputStream(path);
     BufferedInputStream buffered = new BufferedInputStream(in)) {
    byte[] header = buffered.readNBytes(8);
}

readNBytes reads exactly that many bytes or fewer at end of file: unlike read(byte[]), which returns however many happened to be available and is the source of a classic partial-read bug. The symptom is characteristic: the code works on a local file, where a read usually satisfies the whole buffer, and truncates over a network filesystem or a socket, where it usually does not.

Wrapping in a BufferedInputStream is worth it whenever the reads are small. Files.newInputStream returns an unbuffered channel-backed stream, so a byte-at-a-time loop over it is one system call per byte.

transferTo copies a stream without a buffer loop:

try (InputStream in = Files.newInputStream(source);
     OutputStream out = Files.newOutputStream(target)) {
    in.transferTo(out);
}

For a file-to-file copy, Files.copy is better still. It can use a platform-level copy.

Reading a resource from the classpath

A file inside the jar is not a Path:

try (InputStream in = getClass().getResourceAsStream("/templates/email.html")) {
    if (in == null) throw new IllegalStateException("Resource not found");
    String content = new String(in.readAllBytes(), StandardCharsets.UTF_8);
}

getResourceAsStream returns null rather than throwing when the resource is missing, so the check is required: without it the failure is a NullPointerException several lines later.

The leading slash makes the path absolute; without it, it resolves relative to the class’s package. In a modular application the resource’s package must also be opened, or the lookup returns null for a file that is demonstrably in the jar.

Do not use getResource(...).getFile() and pass the result to Path.of. It works when the classes are on disk during development and fails inside a jar, where the resource is not a file, a defect that appears only after packaging.

Exceptions worth catching separately

try {
    return Files.readString(path);
} catch (NoSuchFileException e) {
    // the file is not there
} catch (AccessDeniedException e) {
    // it exists and cannot be read
} catch (MalformedInputException e) {
    // the bytes are not valid in the declared charset
} catch (IOException e) {
    // everything else
}

All four are IOException, and the first three carry a diagnosis a single catch discards. MalformedInputException in particular means the charset is wrong, not that the file is corrupt — reading a Latin-1 file as UTF-8 produces exactly this.

Do not check Files.exists(path) before reading. The state can change in between, and the open already reports the answer, see checking whether a file exists.

Reading structured input

Two shapes come up often enough to be worth naming.

A file of records too large to hold, processed in a streaming pipeline:

try (Stream<String> lines = Files.lines(path, StandardCharsets.UTF_8)) {
    Map<String, Long> counts = lines
            .skip(1)                                   // header
            .map(line -> line.split(",", -1))
            .filter(parts -> parts.length == 4)
            .collect(Collectors.groupingBy(p -> p[2], Collectors.counting()));
}

split(",", -1) with the negative limit keeps trailing empty fields, which the default drops: a row ending in a comma loses its last column otherwise. For real CSV, with quoting and embedded newlines, use Commons CSV rather than split.

A file read repeatedly, where the cost is the open rather than the read. Cache the parsed result and check Files.getLastModifiedTime rather than re-reading, and be aware that the timestamp has filesystem-dependent granularity, a file rewritten within the same second can look unchanged.

Choosing

SituationMethod
Small text file, whole contentFiles.readString
Small text file, as linesFiles.readAllLines
Large text fileFiles.lines in try-with-resources
Line loop with early exit or stateFiles.newBufferedReader
BinaryFiles.newInputStream + readNBytes
Inside the jargetResourceAsStream
Copying to another fileFiles.copy

Related: creating a file and deleting one. More in the Java guides.

Frequently asked questions

What is the simplest way to read a text file?

Files.readString(path). It defaults to UTF-8 on every version and is one line, for files that comfortably fit in memory.

How do I read a file too large for memory?

Files.lines(path) inside try-with-resources, or Files.newBufferedReader with a loop. Both read lazily, so memory is bounded by the longest line.

Why must I close the stream from Files.lines?

It holds an open file handle and does not close itself. Leaking them exhausts the file descriptors, which surfaces later as an unrelated failure.

Files.lines or BufferedReader?

The stream for a filter-map-collect pipeline; the reader when the loop breaks early, carries state, or throws a checked exception.

Do I need to specify a charset?

Name it, or use a method that defaults to UTF-8. Before Java 18, FileReader and new String(bytes) used the platform default, which differs between a laptop and a container.

What does MalformedInputException mean?

The bytes are not valid in the charset you declared — typically a Latin-1 file read as UTF-8. The file is fine; the charset is wrong.

Why does the first field of my CSV have a strange character?

A UTF-8 byte order mark. Java does not strip it, so it becomes the first character of the first line.

How do I read a file bundled in the jar?

getResourceAsStream("/path"). It returns null when missing, so check. Do not convert the URL to a Path — that fails once packaged.

Should I check the file exists first?

No. The state can change between the check and the open, and NoSuchFileException already answers it.

What is the difference between read(byte[]) and readNBytes?

read returns whatever is available, which can be fewer bytes than requested; readNBytes reads the full count or stops at end of file.