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
- 21File I/O and Serialization
- 22Debugging: Errors, Stack Traces, the Debugger and Logging
- 23Maven: What It Does and How to Use It
- 24Git for Java Projects: Commits, Branches, Merging and Pull Requests
- 25Testing with JUnit and Mockito: Unit Tests, Mocks and Their Limits
- 26Project 1: Build an Expense Tracker CLI in Plain Java
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
- 36Security Fundamentals and Spring Security: Passwords, JWT, Access Control, CORS and Secrets
- 37Testing Spring Boot Applications: Slice Tests, MockMvc, Real Databases and the Test Pyramid
- 38Production Practices: Configuration, Logging, Docker and Health Checks for Spring Boot
- 39Project 3: The Job Application Tracker API, Assembled and Reviewed
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.
For
Learners who need to persist and load data from files and who want to understand Java serialization and its pitfalls.
Level
Before you start
Exceptions and try-with-resources (article 10) and Strings and character handling (article 8). Streams (article 16) help for Files.lines.
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
PathandFilesAPI, and how it replaces the oldFileclass. - 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:
Pathrepresents a location in a file system. It is only a name: aPathcan refer to a file that does not exist yet.Filesis 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.
// 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)); }}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: trueThe ideas to take from the program:
resolvebuilds a path from a base and a name, using the right separator for the operating system. Do not glue strings together with"/"or"\\". Other usefulPathmethods aregetFileName,getParent,relativize,normalizeandtoAbsolutePath.writeStringandreadString(Java 11) write or read a whole text file in one call. The default forwriteStringis to create the file or replace its contents, and you pass options to change that:StandardOpenOption.APPENDadds to the end,CREATE_NEWfails if the file already exists.readAllLinesreturns the lines as a list.Files.walk,Files.listandFiles.linesreturn streams that hold open resources. The documentation says explicitly that the directory stream'sclosemethod 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 declarethrows 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 callsFiles.deletedoes not compile (as you saw in article 15), and whydeleteRecursivelyuses a file visitor, a callback object whose methods are allowed to throwIOException. - Real temporary files and folders are created with
Files.createTempFileandcreateTempDirectory. 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.
// 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()); }}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-8The 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.readStringandwriteStringalso use UTF-8 by default. - Console output is a separate matter. JEP 400 notes that
System.outandSystem.errstill follow the platform's console encoding. - Be explicit anyway. Passing
StandardCharsets.UTF_8documents 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.
// 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); } }}errors: 10lines: 10000, last error: ERROR event 10000Buffering 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.
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:
// 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); }}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:
// 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); } } }}report.pdfreport.pdfrejected: ../secret.txtrejected: ../../etc/passwdrejected: a/../../bnormalize() 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.
// 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()); } }}serialized something: truename: Asha, skills: [Java, SQL], token: nullrejected by filter: filter status: REJECTEDThe 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:
- Security. Deserialization can create objects of any
Serializableclass 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. - 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. - Language lock-in. The bytes are meaningful only to Java. Other systems cannot read them.
- Hidden behaviour. Serialization bypasses constructors, so validation in the constructor does not run on deserialization unless you take extra steps, and inheritance and
transientrules are subtle. - 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,walkorlistunclosed. - 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()returningfalseas success (another reason to preferFiles). - 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
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
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
#- Next: Debugging: Errors, Stack Traces, the Debugger and Logging teaches you how to find out what went wrong when files, or anything else, fail.
- For using these ideas in a real application: Project 1: Expense Tracker CLI.
- For persistent data beyond files: SQL and PostgreSQL Fundamentals.
Sources & further reading
- Technology
- Software Engineering
- #file I/O
- #Java
- #Java backend
- #Java basics
- #Java interview
- #JPA
- #learn Java
- #serialization
- #Spring Boot
- #SQL
Connected lessons
References
Linked from 3 guides
Other pages in the library that point here.
Keep reading
More in Technology →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

