Skip to content
SparkTime, home
Java From Zero to Job-Ready
Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
12 · Interviews and Readiness
Lesson 21 of 41

Lesson 21 · Tools and Craft

File I/O and Serialization

How to read and write files in modern Java with Path and Files, why character encoding matters, how to stream large files safely, how to avoid path traversal, and what Java serialization really is, why it is risky, and what to use instead.

On this page

Key takeaways

  • Use java.nio.file.Path and Files for new code. Files.readString, writeString, readAllLines, lines and newBufferedReader cover most needs, and anything that returns a closeable stream or reader belongs in try-with-resources.
  • UTF-8 has been the default charset of the standard Java APIs since Java 18, but be explicit with StandardCharsets.UTF_8 when you read or write text that other systems will use.
  • Stream large files line by line instead of reading them whole. Write important files by writing a temporary file and moving it into place.
  • Never build file paths from untrusted input without normalising and checking that the result stays inside the intended directory.
  • Java native serialization is not a general persistence format. Deserialising untrusted data is dangerous, the format is fragile across versions, and alternatives such as JSON or a database are almost always better.

Variables disappear when a program ends. To keep anything (your expenses, your settings, your to-do list), a program must write to something that outlasts it, and the simplest such thing is a file. Reading and writing files looks easy and has many ways to go wrong: wrong encoding, files that are too big to read into memory, files that were left open, paths that point somewhere you did not intend.

This article covers the modern Java file API, a sensible way to organise file formats, and, in the second half, serialization: Java's built-in way to turn objects into bytes, and why you should be wary of it.

What you will learn

#
  • The Path and Files API, and how it replaces the old File class.
  • How to read and write text safely, with the right encoding.
  • How to process large files without loading them into memory.
  • How to write a file safely, and avoid path traversal.
  • What Java serialization is, its risks, and the alternatives.

Path and Files

#

Java has two file APIs. The old one, java.io.File, dates from Java 1.0 and has awkward error handling (many methods return false instead of explaining what failed). The newer NIO.2 API, in java.nio.file since Java 7, is what you should use:

  • Path represents a location in a file system. It is only a name: a Path can refer to a file that does not exist yet.
  • Files is a utility class of static methods that act on paths. Its documentation describes it as consisting exclusively of static methods that operate on files, directories or other types of files.
Java
// File: Main.javaimport java.io.IOException;import java.nio.file.FileVisitResult;import java.nio.file.Files;import java.nio.file.Path;import java.nio.file.SimpleFileVisitor;import java.nio.file.StandardOpenOption;import java.nio.file.attribute.BasicFileAttributes;import java.util.List;import java.util.stream.Stream;public class Main {    static void deleteRecursively(Path root) throws IOException {        Files.walkFileTree(root, new SimpleFileVisitor<>() {            @Override            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {                Files.delete(file);                return FileVisitResult.CONTINUE;            }            @Override            public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {                Files.delete(dir);                return FileVisitResult.CONTINUE;            }        });    }    public static void main(String[] args) throws IOException {        Path base = Files.createTempDirectory("javacourse");          // a scratch folder for the demo        try {            Path notes = base.resolve("notes").resolve("todo.txt");   // build a path, do not concatenate strings            Files.createDirectories(notes.getParent());               // create missing folders            Files.writeString(notes, "buy milk\nwrite article\n");    // creates or replaces the file            Files.writeString(notes, "call Asha\n", StandardOpenOption.APPEND);            System.out.println("file name: " + notes.getFileName());            System.out.println("parent name: " + notes.getParent().getFileName());            System.out.println("exists: " + Files.exists(notes) + ", size: " + Files.size(notes) + " bytes");            List<String> lines = Files.readAllLines(notes);            System.out.println(lines.size() + " lines: " + lines);            System.out.println("whole file has " + Files.readString(notes).length() + " characters");            Path copy = base.resolve("todo-copy.txt");            Files.copy(notes, copy);            System.out.println("copy exists: " + Files.exists(copy));            try (Stream<Path> entries = Files.walk(base)) {           // streams of paths must be closed                entries.filter(Files::isRegularFile)                        .map(p -> p.getFileName().toString())                        .sorted()                        .forEach(name -> System.out.println("found: " + name));            }        } finally {            deleteRecursively(base);                                  // clean up the scratch folder        }        System.out.println("scratch folder removed: " + !Files.exists(base));    }}
Code
file name: todo.txtparent name: notesexists: true, size: 33 bytes3 lines: [buy milk, write article, call Asha]whole file has 33 characterscopy exists: truefound: todo-copy.txtfound: todo.txtscratch folder removed: true

The ideas to take from the program:

  • resolve builds a path from a base and a name, using the right separator for the operating system. Do not glue strings together with "/" or "\\". Other useful Path methods are getFileName, getParent, relativize, normalize and toAbsolutePath.
  • writeString and readString (Java 11) write or read a whole text file in one call. The default for writeString is to create the file or replace its contents, and you pass options to change that: StandardOpenOption.APPEND adds to the end, CREATE_NEW fails if the file already exists.
  • readAllLines returns the lines as a list.
  • Files.walk, Files.list and Files.lines return streams that hold open resources. The documentation says explicitly that the directory stream's close method should be invoked after iteration (use try-with-resources), as the example does. Forgetting leaks file handles.
  • Checked exceptions. Nearly every method here throws IOException, which is why the methods declare throws IOException. This is a place where the checked-exception design makes sense: disk errors are real, expected and sometimes recoverable. It is also why a lambda that calls Files.delete does not compile (as you saw in article 15), and why deleteRecursively uses a file visitor, a callback object whose methods are allowed to throw IOException.
  • Real temporary files and folders are created with Files.createTempFile and createTempDirectory. Always clean them up.

Character encoding: bytes versus text

#

A file contains bytes. Text is only text once you decide how to turn bytes into characters. That mapping is the character encoding. The same bytes decode to different text under different encodings, and a wrong choice produces garbage ("mojibake") or lost characters.

Java
// File: Main.javaimport java.nio.charset.Charset;import java.nio.charset.StandardCharsets;public class Main {    static String codePoints(String text) {        StringBuilder sb = new StringBuilder();        text.codePoints().forEach(cp -> sb.append(String.format("U+%04X ", cp)));        return sb.toString().trim();    }    public static void main(String[] args) {        String word = "café";                                    // "cafe" with an accented e        byte[] utf8 = word.getBytes(StandardCharsets.UTF_8);        byte[] latin1 = word.getBytes(StandardCharsets.ISO_8859_1);        System.out.println("UTF-8 bytes: " + utf8.length + ", ISO-8859-1 bytes: " + latin1.length);        String correct = new String(utf8, StandardCharsets.UTF_8);        String garbled = new String(utf8, StandardCharsets.ISO_8859_1);   // decoded with the wrong charset        System.out.println("correct: " + codePoints(correct));        System.out.println("garbled: " + codePoints(garbled));        System.out.println("default charset: " + Charset.defaultCharset());    }}
Code
UTF-8 bytes: 5, ISO-8859-1 bytes: 4correct: U+0063 U+0061 U+0066 U+00E9garbled: U+0063 U+0061 U+0066 U+00C3 U+00A9default charset: UTF-8

The accented letter takes two bytes in UTF-8 and one in ISO-8859-1. Decoding the UTF-8 bytes as ISO-8859-1 turned one character into two wrong ones (U+00C3 U+00A9, the classic "é"). The program prints code points instead of the letters themselves so that the output does not depend on your terminal's encoding.

Practical rules:

  • UTF-8 is the right default for new files, and almost all modern systems use it. Since Java 18 (JEP 400), UTF-8 is the default charset of the standard Java APIs regardless of the operating system or locale. Before that, the default depended on the platform, which caused many bugs. Files.readString and writeString also use UTF-8 by default.
  • Console output is a separate matter. JEP 400 notes that System.out and System.err still follow the platform's console encoding.
  • Be explicit anyway. Passing StandardCharsets.UTF_8 documents your intent, and protects you if the code runs on an older JDK or reads a file produced elsewhere.
  • Never guess: if a file arrives in another encoding, you must be told which one.

Reading large files: stream, do not load

#

readAllLines and readString load the entire file into memory. For a few kilobytes that is fine. For a 5 GB log it is an OutOfMemoryError. For large files, read line by line and process each line as it arrives: Files.lines(path) gives a Stream<String> that is filled lazily, and Files.newBufferedReader(path) gives a BufferedReader for a classic loop.

Java
// File: Main.javaimport java.io.BufferedReader;import java.io.IOException;import java.nio.charset.StandardCharsets;import java.nio.file.Files;import java.nio.file.Path;import java.util.ArrayList;import java.util.List;import java.util.stream.Stream;public class Main {    public static void main(String[] args) throws IOException {        Path log = Files.createTempFile("app", ".log");        try {            List<String> content = new ArrayList<>();            for (int i = 1; i <= 10_000; i++) {                content.add((i % 1_000 == 0 ? "ERROR " : "INFO ") + "event " + i);            }            Files.write(log, content, StandardCharsets.UTF_8);        // write a list of lines            // Streaming: the file is never fully in memory            try (Stream<String> lines = Files.lines(log, StandardCharsets.UTF_8)) {                long errors = lines.filter(l -> l.startsWith("ERROR")).count();                System.out.println("errors: " + errors);            }            // The classic buffered loop, with explicit control            int total = 0;            String lastError = null;            try (BufferedReader reader = Files.newBufferedReader(log, StandardCharsets.UTF_8)) {                String line;                while ((line = reader.readLine()) != null) {                    total++;                    if (line.startsWith("ERROR")) {                        lastError = line;                    }                }            }            System.out.println("lines: " + total + ", last error: " + lastError);        } finally {            Files.deleteIfExists(log);        }    }}
Code
errors: 10lines: 10000, last error: ERROR event 10000

Buffering matters because reading or writing one byte or character at a time means one call to the operating system each time. A BufferedReader or BufferedWriter reads or writes in large chunks and hands you pieces from memory. The Files methods already buffer internally, but when you wrap raw streams yourself (new FileReader(...)) you must add the buffer. Always use try-with-resources so that the file is closed even if processing fails (article 10).

Writing files safely

#

If a program crashes while rewriting a file, you may be left with a half-written file and lose the old data too. The safer pattern is write to a temporary file in the same directory, then move it over the original. Files.move with StandardCopyOption.ATOMIC_MOVE replaces the target in one step on file systems that support it. The documentation notes that if the move cannot be performed as an atomic file system operation, an AtomicMoveNotSupportedException is thrown (for example when the target is on a different file store), which is why the temporary file belongs in the same directory.

Java
Path temp = target.resolveSibling(target.getFileName() + ".tmp");Files.write(temp, lines, StandardCharsets.UTF_8);Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);

Project 1 uses this pattern to save its data (article 26).

File formats: be careful with the simplest one

#

Plain text and CSV (comma-separated values) are tempting for storing records. The trap is delimiters inside the data:

Java
// File: Main.javaimport java.util.ArrayList;import java.util.List;public class Main {    // Naive: split on every comma    static String[] naive(String line) {        return line.split(",");    }    // A small, correct reader for one CSV line with quoted fields    static List<String> parseCsvLine(String line) {        List<String> fields = new ArrayList<>();        StringBuilder current = new StringBuilder();        boolean inQuotes = false;        for (int i = 0; i < line.length(); i++) {            char c = line.charAt(i);            if (inQuotes) {                if (c == '"' && i + 1 < line.length() && line.charAt(i + 1) == '"') {                    current.append('"');            // "" inside quotes is a literal quote                    i++;                } else if (c == '"') {                    inQuotes = false;                } else {                    current.append(c);                }            } else if (c == '"') {                inQuotes = true;            } else if (c == ',') {                fields.add(current.toString());                current.setLength(0);            } else {                current.append(c);            }        }        fields.add(current.toString());        return fields;    }    static String quote(String field) {        if (field.contains(",") || field.contains("\"") || field.contains("\n")) {            return "\"" + field.replace("\"", "\"\"") + "\"";        }        return field;    }    public static void main(String[] args) {        String description = "Lunch, with \"extra\" cheese";        String line = "2026-10-05," + quote(description) + ",450.00";        System.out.println(line);        System.out.println("naive split gives " + naive(line).length + " fields");        List<String> parsed = parseCsvLine(line);        System.out.println("quote-aware parser gives " + parsed.size() + " fields: " + parsed);    }}
Code
2026-10-05,"Lunch, with ""extra"" cheese",450.00naive split gives 4 fieldsquote-aware parser gives 3 fields: [2026-10-05, Lunch, with "extra" cheese, 450.00]

A description containing a comma breaks the naive parser: it finds four fields where there are three. Real CSV needs quoting rules, and the rules have more corners than the small parser above (fields containing line breaks, different delimiters, a byte-order mark). For anything beyond a throwaway file, use a well-tested library for CSV, and use JSON (with a library such as Jackson, which you will meet in the Spring Boot articles) for structured data. If you design your own line format, pick delimiters that cannot occur in the data, or escape them.

Path traversal: untrusted file names

#

Whenever a file name or path comes from outside your program (a user, a request, a file inside an upload), a malicious value such as ../../etc/passwd can try to reach files you never meant to expose. This is called path traversal. The defence is to resolve the path against your base directory, normalise it, and check that the result is still inside the base:

Java
// File: Main.javaimport java.nio.file.Path;public class Main {    static Path safeResolve(Path baseDir, String untrustedName) {        Path base = baseDir.toAbsolutePath().normalize();        Path candidate = base.resolve(untrustedName).normalize();        if (!candidate.startsWith(base)) {            throw new IllegalArgumentException("path escapes the base directory: " + untrustedName);        }        return candidate;    }    public static void main(String[] args) {        Path base = Path.of("uploads");        System.out.println(safeResolve(base, "report.pdf").getFileName());        System.out.println(safeResolve(base, "2026/october/report.pdf").getFileName());        for (String attack : new String[] {"../secret.txt", "../../etc/passwd", "a/../../b"}) {            try {                safeResolve(base, attack);            } catch (IllegalArgumentException e) {                System.out.println("rejected: " + attack);            }        }    }}
Code
report.pdfreport.pdfrejected: ../secret.txtrejected: ../../etc/passwdrejected: a/../../b

normalize() collapses . and .. segments, and startsWith(base) then verifies containment. The better design is to avoid user-supplied paths altogether: generate your own file names (for instance a random identifier) and keep the original name only as metadata. Also remember that symbolic links can point outside a directory, and that file permissions and the account running your program are a second line of defence.

Serialization

#

Serialization means turning an object (and everything it references) into a sequence of bytes that can be stored or sent, and deserialization means rebuilding the object from those bytes. Java has a built-in mechanism: a class opts in by implementing the marker interface Serializable, and ObjectOutputStream and ObjectInputStream do the work.

Java
// File: Main.javaimport java.io.ByteArrayInputStream;import java.io.ByteArrayOutputStream;import java.io.IOException;import java.io.InvalidClassException;import java.io.ObjectInputFilter;import java.io.ObjectInputStream;import java.io.ObjectOutputStream;import java.io.Serializable;import java.util.ArrayList;import java.util.List;public class Main {    static class Profile implements Serializable {        private static final long serialVersionUID = 1L;       // a version label for the serialized form        private final String name;        private final List<String> skills = new ArrayList<>();        private transient String sessionToken;                  // transient: deliberately not written        Profile(String name, String sessionToken) {            this.name = name;            this.sessionToken = sessionToken;        }    }    static class Unexpected implements Serializable {        private static final long serialVersionUID = 1L;    }    static byte[] serialize(Object o) throws IOException {        ByteArrayOutputStream bytes = new ByteArrayOutputStream();        try (ObjectOutputStream out = new ObjectOutputStream(bytes)) {            out.writeObject(o);        }        return bytes.toByteArray();    }    public static void main(String[] args) throws Exception {        Profile original = new Profile("Asha", "secret-token-123");        original.skills.add("Java");        original.skills.add("SQL");        byte[] data = serialize(original);        System.out.println("serialized something: " + (data.length > 0));        // Deserialize with an allow-list filter: only the classes we expect are accepted        ObjectInputFilter allowProfileOnly = ObjectInputFilter.Config.createFilter(                "Main$Profile;java.util.ArrayList;java.lang.Object;java.lang.String;!*");        try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(data))) {            in.setObjectInputFilter(allowProfileOnly);            Profile copy = (Profile) in.readObject();            System.out.println("name: " + copy.name + ", skills: " + copy.skills + ", token: " + copy.sessionToken);        }        // The same filter refuses a class that was not on the list        byte[] other = serialize(new Unexpected());        try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(other))) {            in.setObjectInputFilter(allowProfileOnly);            in.readObject();        } catch (InvalidClassException e) {            System.out.println("rejected by filter: " + e.getMessage());        }    }}
Code
serialized something: truename: Asha, skills: [Java, SQL], token: nullrejected by filter: filter status: REJECTED

The round trip worked, and the transient field was not saved (it came back as null). The serialVersionUID is a version label: if the class changes and the label does not match what is in the data, deserialization fails with an InvalidClassException. The second half shows a serialization filter (JEP 290, Java 9): the pattern allows exactly the classes we expect and rejects everything else with !*, so the unexpected class was refused. (Finding the right allow-list takes a little trial and error. A first attempt without java.lang.Object was rejected too, because an ArrayList keeps its elements in an Object[] array.)

Why you should be careful with it

#

Java native serialization looks convenient and has serious drawbacks, which is why it is not the recommended way to store or exchange data in a modern application:

  1. Security. Deserialization can create objects of any Serializable class that is on the classpath, and run their code during reconstruction. If the bytes come from an untrusted source, an attacker can craft a stream that triggers harmful behaviour in classes you did not even know were involved. This class of vulnerability is well known and a recurring cause of serious security bugs. The JEP 290 filter mechanism exists because of it: it states that security guidelines consistently require input from external sources to be validated before use, and it allows allow-lists and deny-lists of classes and limits on depth and size. Never deserialize data that you did not produce, and never without a filter.
  2. Fragility. The format is tied to your class structure. Renaming a field, moving a class or changing a type can break old data, even with serialVersionUID.
  3. Language lock-in. The bytes are meaningful only to Java. Other systems cannot read them.
  4. Hidden behaviour. Serialization bypasses constructors, so validation in the constructor does not run on deserialization unless you take extra steps, and inheritance and transient rules are subtle.
  5. Performance and size. The format is verbose.

What to use instead: for configuration and data exchange, JSON (with Jackson or a similar library), or another explicit, language-neutral format such as Protocol Buffers. For persistent application data, a database (article 27). For simple local files, an explicit text format whose reading and writing you control, with validation on load. In these designs you decide exactly what is written, and you can validate everything that is read. Records (article 9) make small, explicit data classes easy to convert to and from such formats.

You still need to recognise Serializable, because it appears in old code, in some framework classes (HTTP sessions, caches), and in interviews. The knowledge you need: what it does, the role of transient and serialVersionUID, and, above all, the security warning.

Common mistakes

#
  • Concatenating paths with strings instead of Path.resolve.
  • Leaving streams from Files.lines, walk or list unclosed.
  • Reading a huge file entirely into memory.
  • Relying on the platform's default encoding when exchanging files, or ignoring the encoding on older JDKs.
  • Overwriting a file in place and losing data on a crash.
  • Parsing CSV with split(",").
  • Using user input as part of a path without checking containment.
  • Treating File.delete() returning false as success (another reason to prefer Files).
  • Deserializing untrusted data, or using native serialization as the persistent format of an application.

Exercises

#

Exercise 1

Count lines

#

Write a method that returns the number of non-blank lines in a text file without loading the whole file into memory.

Exercise 2: Append safely

#

Write code that appends a line to a log file, creating the file if it does not exist, using UTF-8 explicitly.

Exercise 3: Find the vulnerability

#

A service builds a path with Path.of("/data/reports/" + request.getParameter("name")) and returns the file contents. Describe an attack, and rewrite the code safely.

Exercise 4: Decide the format

#

You need to store a list of user settings (theme, language, notification flags) that a human may edit and another program may read. Compare native serialization, CSV and JSON, and choose.

Exercise 5: Debug the encoding

#

A file written on one machine shows é where é should be when read on another. Explain the likely cause and the fix.

Show solutionHide solution
Java
static long countNonBlank(Path file) throws IOException {    try (Stream<String> lines = Files.lines(file, StandardCharsets.UTF_8)) {        return lines.filter(line -> !line.isBlank()).count();    }}

Files.lines is lazy, so the file is processed line by line. The try-with-resources closes the underlying file even if an exception occurs.

Exercise 2

Show solutionHide solution
Java
Files.writeString(log, message + System.lineSeparator(), StandardCharsets.UTF_8,        StandardOpenOption.CREATE, StandardOpenOption.APPEND);

CREATE creates the file if it is missing, and APPEND adds to the end instead of replacing the contents. Passing the charset explicitly documents the encoding.

Exercise 3

Show solutionHide solution

An attacker supplies name=../../etc/passwd (or a similar sequence), and the path escapes /data/reports, exposing arbitrary files. Safe version: resolve the name against the base directory, normalise, and verify that the result still starts with the base, as in safeResolve in this article. Better still, never accept paths: accept an identifier, look up the file name server-side in a database or a map, and use a generated file name.

Exercise 4

Show solutionHide solution

Native serialization is out: it is Java-only, unreadable and unsafe to load from a file a person may edit. CSV is poorly suited to settings with a nested or varied structure. JSON is human-readable, editable, language-neutral and supported by mature libraries, and it can be validated when loaded. (A properties file is also reasonable for a flat list of keys.) Choose JSON.

Exercise 5

Show solutionHide solution

The classic cause is UTF-8 bytes being decoded as ISO-8859-1 (or Windows-1252): the two bytes of the UTF-8 é are each read as one character, giving é. The reader used a different charset from the writer. Fix it by reading and writing with an explicit, shared charset (StandardCharsets.UTF_8) and by checking that tools such as editors and terminals use UTF-8 too. If old files were written in another encoding, convert them once.

Where to go next

#

Sources & further reading

  1. docs.oracle.com
  2. openjdk.org
  3. openjdk.org
  • Guide · Technology

    Java Essentials for DSA

  • Guide · Technology

    Heaps and Priority Queues

  • Course · Technology

    DSA From Zero to Job-Ready: A Practical Guide with Java