1. Why file operations break
When you write code, you really want to believe the computer is an obedient calculator: you say “read the file” and it reads it. In practice, the computer is closer to a cat: sometimes it pretends you do not exist. The file may have been deleted, moved, access may have been revoked, a folder may have been replaced with a file (yes, that happens), the disk may run out of space, an antivirus may block the operation, and on Windows a file can be “in use” by another process.
Key idea: I/O (input/output) is a high-uncertainty zone. In arithmetic, 2 + 2 is almost always 4. With files, “read text” can mean “try to negotiate with the OS, the file system, permissions, and the current state of the disk”. And if that “negotiation” fails, you get an exception.
exists() is not a guarantee
The most common beginner trap sounds like this: “I checked exists(), so readText() will definitely work.” Unfortunately, no. Between the check and the actual read operation, the world can change. The file may be deleted, permissions may change, a directory may be renamed, the disk may disappear (yes, sometimes literally).
This is the classic effect of “checked—and only then learned the truth”. So the rule is simple and a bit philosophical: checks are useful for diagnostics and early messages, but you still need try/catch around the real I/O.
Bad (naive) code:
import java.io.File
fun main() {
val file = File("data/input.txt")
if (file.exists()) {
val text = file.readText() // can still crash
println(text.length)
} else {
println("File does not exist")
}
}
A more grown-up minimum (check + protection):
import java.io.File
import java.io.IOException
fun main() {
val file = File("data/input.txt")
println("Before reading: exists=${file.exists()}")
try {
val text = file.readText()
println("Read: ${text.length}") // for example: Read: 120
} catch (e: IOException) {
println("Read failed: ${file.path}")
}
}
The idea is not to “cover everything with checks”, but to accept this: a check is a hint, and try/catch is the last line of defense.
2. IOException and FileNotFoundException on the JVM
Before catching exceptions, it helps to understand what we are catching at all. In Kotlin (on the JVM), exceptions are objects, and all of them inherit from Throwable. Next come Error and Exception, and inside Exception live the various “working” problems that we can usually handle.
Where IOException lives in the hierarchy
A very practical way to read this fact is: IOException is an “umbrella” for everything related to reading/writing/opening streams. You do not have to remember every specific subclass, but it is important to understand the pattern: if you do real I/O, it may throw IOException.
Mini example: “catch the umbrella exception”:
import java.io.File
import java.io.IOException
fun main() {
val file = File("data/input.txt")
try {
val text = file.readText()
println("OK: ${text.length}") // for example: OK: 42
} catch (e: IOException) {
println("I/O error while reading: ${file.path}")
println("Reason: ${e.message}")
}
}
Even if you know nothing about file systems, you can already make your program noticeably more resilient—not “crash”, but report what went wrong.
FileNotFoundException: “file not found” and other surprises
The name FileNotFoundException sounds as if there is always a single reason: “file not found”. And sometimes that is true. But in reality this type often means a broader category: “failed to open the path as a file”.
That is, you may hit one of these scenarios: the file truly does not exist, the path points to a directory, the process has no read permissions, or the file exists but access is denied/impossible at the OS level. For a beginner, this is unpleasant: you checked with your own eyes that the file is “there”, yet the program still says “not found”. What it means is: “I could not open this path as a readable file.”
At this point it helps to start thinking like a diagnostician: we do not argue with the exception (“but it exists!”), we clarify reality: what kind of object is at that path and what basic properties it has right now.
Here is a minimal check that often answers half the questions immediately:
import java.io.File
fun main() {
val file = File("data/input.txt")
println("exists=${file.exists()}") // exists=true/false
println("isFile=${file.isFile}") // isFile=true/false
println("isDir=${file.isDirectory}") // isDir=true/false
}
If you see exists=true, but isFile=false and isDir=true, then you tried to read a directory as a file. That is not a “Kotlin bug”; it is an “expectations bug”.
Careful try/catch: specific → general
Many beginners want to catch Exception right away, because “that will catch everything for sure”. The problem is that afterward you effectively turn your brain off: you do not build the habit of distinguishing causes. Kotlin (and the JVM) encourage catching more specific exceptions first, and the more general type later. That is why the order of catch matters: from more specific to more general.
A good basic template for reading:
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
fun main() {
val file = File("data/input.txt")
try {
val text = file.readText()
println("OK, length=${text.length}")
} catch (e: FileNotFoundException) {
println("Not found/cannot be opened as a file: ${file.path}")
println("Diagnostics: ${describePath(file)}")
} catch (e: IOException) {
println("I/O error while reading: ${file.path}")
println("Reason: ${e::class.simpleName}: ${e.message}")
}
}
What matters here is that we do not pretend these are “the same errors”. If the file cannot be opened as a file, we say so separately. If it is “some more general I/O issue”, we can also report a clear minimum.
3. Diagnosing I/O errors
When you catch IOException, you see the tip of the iceberg: “failed”. The user (and future you) needs to understand why. The good news is: there are not that many typical causes, and it is convenient to keep them in mind as a map.
Mini map of causes
Below is a small table (without trying to cover the entire universe) that helps you “make a diagnosis” quickly:
| What you tried to do | Common real cause | How it looks |
|---|---|---|
| Read a file | No file / wrong path | |
| Read a file | The path is a directory | FileNotFoundException or IOException |
| Read a file | No read permission | often FileNotFoundException or AccessDenied… in the message |
| Write a file | No parent directory | sometimes FileNotFoundException (because opening for write failed) |
| Write a file | Disk is “full” | IOException, the message may hint at space/quota |
| Read/write | File is in use/locked | more often IOException, depends on the OS |
Right now our goal is not “to learn to fix everything in the world”, but to learn to correctly distinguish at least two big situations:
1) “Not found / not opened as a file” (often FileNotFoundException)
2) “Opened, but then reading/writing broke” (often the general IOException)
And we build handling and messages around that.
What to print so you do not have to guess
There is a temptation to do this: catch the exception—print “Something went wrong”—go drink tea. But that is a path to endless tea and endless pain. Diagnostics for file operations almost always revolves around three things: the action, the path, brief properties of the object at that path.
Imagine someone sent you a screenshot of an error (without context). What would you want to see? Usually this: “I tried to READ or WRITE”, “here is the path”, “here is what was there”, “here is the exception type and message”. That is enough to understand the cause in most cases.
Let us make a small function that describes a path. This is not “future architecture”; it is just a convenient printable label:
import java.io.File
fun describePath(file: File): String {
return "path=${file.path}, exists=${file.exists()}, isFile=${file.isFile}, isDir=${file.isDirectory}"
}
fun main() {
val file = File("data/input.txt")
println(describePath(file))
// for example: path=data/input.txt, exists=false, isFile=false, isDir=false
}
Now, when an error happens, you can print one line and greatly increase your chances of quickly understanding the cause.
Example: read expenses and do not “crash” the app
So this does not remain theory, let us mentally continue our training console app (let it be a simple expense tracker). We want to learn to read the file data/expenses.txt so the data survives a program restart.
And then it suddenly turns out: if the file does not exist, it is not a “catastrophe”—it is a normal situation. For example, the user runs the program for the first time. So we need a clear contract: either we read the lines, or we return an empty list and print diagnostics.
Let us make a function that does not crash but returns a list of strings. Right now we are practicing specifically diagnostics for read errors, without atomic writes and without complex reliability.
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
fun loadExpenseLines(file: File): List<String> {
return try {
file.readLines()
} catch (e: FileNotFoundException) {
println("No expenses file: ${file.path}")
emptyList()
} catch (e: IOException) {
println("Failed to read expenses: ${file.path}")
println("Reason: ${e.message}")
emptyList()
}
}
And we use it in main:
import java.io.File
fun main() {
val file = File("data/expenses.txt")
val lines = loadExpenseLines(file)
println("Expense lines: ${lines.size}") // for example: Expense lines: 0
}
Why is this a good style for a beginner? Because the application keeps running. It can show a menu, let the user add an expense, and only then save the file (about saving and reliability—later). And most importantly: if something goes wrong, you can already see which file and what exactly failed.
4. Common mistakes when working with IOException and diagnostics
Mistake #1: catch Exception and write “something went wrong”.
Such code is formally “resilient”, but practically useless: you hid the cause from yourself. A minimum quality bar is to mention the action (read/write), the path (file.path), and e.message in the message. Otherwise you turn debugging into guesswork.
Mistake #2: treat FileNotFoundException as literally “the file is missing”.
Beginners often argue with the exception: “but it is there!”. In reality it often means “failed to open as a file”: the path may point to a directory, access may be denied, or the file exists but cannot be opened under current conditions. That is why, with this exception, it is especially useful to print diagnostics exists/isFile/isDirectory.
Mistake #3: think that exists() guarantees read/write success.
An existence check is a snapshot of the world at one specific millisecond. After that, the world can change. So checks are good as an early warning and as part of the message, but the real operation must still be inside try/catch, because that is where you collide with reality.
Mistake #4: lose the context of “what we were doing” and “with what”.
If an app has multiple files (for example, data/expenses.txt, data/categories.txt, data/report.txt), then a message like “Failed to read file” without the path becomes a riddle. The rule is simple: every I/O error message must include the path and the operation. Then even a user (not a programmer) can describe the problem to you properly.
Mistake #5: mix an I/O error and a data error into one bucket.
If the file was read, but the content is garbage (for example, a line does not parse), that is another category of problems: “data format”. Today we intentionally keep the focus on I/O: “read/did not read”, “wrote/did not write”. If you mix this into one catch, later it will be hard to understand what to fix: the path or the parser.
Mistake #6: pretend the exception message is not important.
Sometimes a developer prints only the exception type and forgets about e.message. But that is where the operating system often leaves the key clue: access denied, no such file, disk full, and so on. Even if the text is in English, it is still better than total silence.