1. Resource: why you must close a file
When you read a file via readText() or readLines(), it can feel like “magic, just give me a string.” But under the hood something very “physical” happens: the operating system allocates a resource to your process — an open file descriptor/stream. While the resource is open, the OS maintains the “program ↔ file” connection and expects the program to politely say: “I’m done, you can close it.”
A resource has an unpleasant property: if you don’t close it, it won’t disappear “by itself” exactly when it’s convenient for you. It may close later (when the garbage collector runs), or it may not close quickly enough for it not to become a problem right now. That’s why working with files is always about discipline: opened → used → closed, preferably without the “oops, I forgot.”
To make the idea stick visually, you can think of it like this:
flowchart TD
A["Opened a file (Reader/Writer/Stream)"] --> B["Read / wrote data"]
B --> C["Closed the resource (close)"]
B -->|If an error happened| D["Exception"]
D --> C
The main point: closing must happen both on “success” and on “error.”
2. Two approaches to closing resources
Manual close()
At the beginning it’s easy to believe you can do this: open a BufferedReader, read, then call close() at the end. On paper it looks great. In real code, you get “branches of fate”: early return, exceptions, break, continue, one more return, because “well there’s an error here, why continue.”
And then in some branch you exit the function earlier than reaching close(). Or an exception suddenly flies in during reading — and close() doesn’t get called either. The result: the file stays open, and you stare at the error thinking: “Someone stole my handle, but who?”
A reliable but more “verbose” approach is try/finally. It guarantees that finally runs even if an exception occurs, and that’s the fundamental idea.
A mini-example with a “training resource”:
class FakeResource {
fun work() {
println("Working...") // Working...
error("Boom") // simulate a failure
}
fun close() {
println("Closed") // Closed
}
}
fun main() {
val r = FakeResource()
try {
r.work()
} finally {
r.close()
}
}
Does it work? Yes. Is it pretty? Let’s say it’s like carrying a screwdriver to open a bottle. You can, but you want a “button.”
That “button” in Kotlin is use { ... }.
use {}: what it is and what guarantee it provides
use { ... } is the idiomatic Kotlin way (similar to Java try-with-resources, just without separate syntax). The meaning is the same: you give Kotlin a resource and a code block, Kotlin executes the block and then automatically closes the resource — regardless of whether the block finished normally or threw an exception.
One more important detail: use is a generic function applicable to closeable resources. That is, it works for types that satisfy the “closeable” contract (for example, Closeable).
Psychologically, it’s convenient to read this code as: “here’s a resource, use it in the block, then close it.”
Comparison of try/finally and use (not as “better/worse,” but as “what you write by hand”):
| What we do | |
|
|---|---|---|
| Guarantee closing | Yes | Yes |
| Longer code | Usually yes | Usually no |
| Where closing happens | In finally | Automatically after the block |
| Risk of “forgot close” | Exists | Almost none (if you write .use { ... } immediately) |
A mini-example with a real file (the idea, without error handling):
import java.io.File
fun main() {
val firstLine = File("data/input.txt")
.bufferedReader()
.use { br -> br.readLine() }
println(firstLine) // for example: Hello
}
Here the BufferedReader will be closed automatically after exiting the use block.
3. Reading a file via bufferedReader().use { ... }
Up to this point you may have read files using “short methods” like readText() and readLines(). They’re great when the file is small and you truly want to load its entire contents into memory. But as soon as the idea “read gradually” appears (for example, find the first matching line or compute statistics), it’s more convenient to open a reader and read line by line.
That’s what bufferedReader() is for: it gives you an object that has readLine(). And to avoid thinking about closing — we wrap it in use right away.
Example: read the first non-blank line
import java.io.File
fun main() {
val firstNonBlank = File("data/input.txt").bufferedReader().use { br ->
var line: String?
while (true) {
line = br.readLine() ?: break
if (line.isNotBlank()) return@use line.trim()
}
null
}
println(firstNonBlank) // for example: Title
}
Notice return@use: this is a “return from the lambda,” not from main. I’m intentionally using a label to make the behavior as obvious as possible, even if you already know about inline and non-local return.
Example: count lines without loading the whole file
import java.io.File
fun main() {
val count = File("data/input.txt").bufferedReader().use { br ->
var lines = 0
while (br.readLine() != null) lines++
lines
}
println("lines=$count") // lines=42
}
Key style: inside use we do only work with the reader. Once you start building half of the application’s business logic inside use, the code becomes harder to read and debug. use is a “short hallway to the resource,” not a “3-hour negotiation room.”
4. Writing a file via bufferedWriter().use { ... }
writeText() and appendText() are also fine: they’re short and clear. But sometimes you want to write in chunks: a header, then lines, then a summary, then a newline. You can pre-concatenate strings (for example, using StringBuilder), but sometimes it’s simpler and more honest to write as you generate.
Then it’s convenient to take bufferedWriter() and again wrap it with use. In Kotlin, use closes the resource automatically, and closing a writer usually includes the final “flush” of the buffer (you don’t need to call flush() manually in learning examples). The idea “closed means definitely written” is very pleasant here.
Example: write a small report line by line.
import java.io.File
fun main() {
val out = File("data/reports/summary.txt")
out.parentFile?.mkdirs()
out.bufferedWriter().use { w ->
w.write("Report\n")
w.write("items=3\n")
w.write("ok=true\n")
}
}
If an exception suddenly happens during writing, use will still attempt to close the writer. That’s exactly the “cleanup on error” guarantee we started this whole lecture for.
A separate nuance about newlines: write() does not add "\n" by itself, so you control the format manually. That’s good: there will be no “magic” newlines, but the responsibility is yours (yes, a programmer is someone who adds "\n" so they don’t cry later).
5. useLines{}: lazy lines and block boundaries
Sometimes you want the convenience of line-by-line processing, but without manually using BufferedReader and while. For that there is useLines { ... }. It opens the file, gives you a “stream of lines” (conceptually, a lazy sequence), and after the block finishes it reliably closes the file.
It’s like: “I want to work with lines like a collection, but I don’t want to keep the whole file in memory.” Inside the block you usually do something like count, filter, map, firstOrNull, and so on — and you get a final value.
Example: count non-blank lines.
import java.io.File
fun main() {
val nonBlank = File("data/input.txt").useLines { lines ->
lines.count { it.isNotBlank() }
}
println("nonBlank=$nonBlank") // nonBlank=10
}
Example: find the first line that looks like a header (for example, starts with "#").
import java.io.File
fun main() {
val header = File("data/input.txt").useLines { lines ->
lines.firstOrNull { it.trim().startsWith("#") }
}
println(header) // for example: # Shopping list
}
Now — the most important idea, worth pausing for.
useLines closes the file after the block. That means you can’t honestly return the “lines” outward and keep reading them later — the file is already closed. This is a typical logical error: it looks like you “returned a Sequence,” but a Sequence doesn’t store data “inside”; it pulls it from the file during iteration. And by that time the file has already been closed.
The correct strategy is simple: either do the computation inside useLines and return the final result (number/string/list), or use readLines() if you truly need a list of lines in memory.
6. Practical example: saving and loading expenses
From this point on, in a learning app it becomes natural to introduce the simplest text storage. Not a “database,” not an “ideal format,” but an honest file you can open in a text editor and understand with your eyes. We’re not discussing encodings and complex formats — today the goal is specifically a safe I/O style.
Let’s imagine that in the project we already have an expense model:
data class Expense(
val amount: Int,
val category: String,
val note: String
)
Let’s make a very simple line format: "amount;category;note". Yes, semicolon-separated is not “ideal,” but it’s easy to parse via split(";").
Serialization (object → string):
fun serializeExpense(e: Expense): String {
val safeNote = e.note.replace(";", ",")
return "${e.amount};${e.category};$safeNote"
}
Parsing (string → object or null if the line is broken):
fun parseExpenseOrNull(line: String): Expense? {
val parts = line.split(";")
if (parts.size < 3) return null
val amount = parts[0].toIntOrNull() ?: return null
return Expense(amount, parts[1], parts[2])
}
Now the most interesting part: loading a list of expenses via useLines, so the file is closed automatically, and the processing feels like working with a collection.
import java.io.File
fun loadExpenses(file: File): List<Expense> {
if (!file.exists() || !file.isFile) return emptyList()
return file.useLines { lines ->
lines.mapNotNull { parseExpenseOrNull(it.trim()) }.toList()
}
}
Notice the style: we return an already finished List<Expense>. The file is closed immediately after the block, and that’s exactly the behavior we want.
Writing the list of expenses via bufferedWriter().use { ... }:
import java.io.File
fun saveExpenses(file: File, items: List<Expense>) {
file.parentFile?.mkdirs()
file.bufferedWriter().use { w ->
for (e in items) w.write(serializeExpense(e) + "\n")
}
}
And a small main fragment that shows everything is connected (without complicating the command-line interface):
import java.io.File
fun main() {
val storage = File("data/expenses.txt")
val items = loadExpenses(storage)
println("Loaded: ${items.size}") // Loaded: 0 (if the file doesn't exist yet)
saveExpenses(storage, items + Expense(120, "food", "coffee"))
}
The “magic” here is not that we can store expenses (we could already do that in a list), but that the I/O code doesn’t create a time bomb for you: the resource will always be closed, even if something crashes midway. That’s exactly what makes use such an important part of an adult coding style.
7. Common mistakes when working with use {} and Reader/Writer
Mistake #1: open bufferedReader() and forget .use { ... }.
This most often happens when you “quickly test an idea” and write val br = file.bufferedReader(), and then start reading. While the program is small, you don’t see the problem. Then an exception or early exit happens, close() is not called, and weird things start: the file is “busy,” data is “not fully written,” on Windows something “won’t let you delete the file.” The cure is a habit: if you see bufferedReader()/bufferedWriter() — your hand automatically adds .use { ... }.
Mistake #2: try to return lines from useLines to the outside.
This is a logical trap: it seems like you returned “the lines,” but in reality you returned a mechanism for reading from the file. And the file is closed immediately after the useLines block. The correct approach is to compute/filter/collect the result inside useLines and return an already finished value, for example Int, String, or List<String>.
Mistake #3: put a lot of business logic inside the use block.
use should be a “narrow contact point with the resource”: read/write — and exit. If inside the block you start updating a bunch of structures, parsing commands, building reports, and also printing to the console, then during debugging you lose the simple rule: “inside use — only I/O.” The code becomes sticky and harder to test.
Mistake #4: forget about "\n" when writing via write().
After appendText(), many get used to “it will somehow do it.” But writer.write() writes exactly what you give it. If you forget "\n", the lines will “stick together” into one, and then your parser will honestly say: “there aren’t 3 parts, there is 1” — and it will be right. So for line-by-line writing, either add "\n" manually, or use a format convention (for example, “each entity is a separate line”).
Mistake #5: confuse “closing a resource” and “handling an error.”
use guarantees closing the resource. But it doesn’t turn errors into success and doesn’t “silence” exceptions automatically. If the file does not exist or you don’t have permissions — you’ll get an exception where you perform the I/O. So keep the separation of responsibilities in mind: use is responsible for closing, and handling I/O errors is a separate topic and a separate design (and you already know the basics via try/catch, but don’t mix everything into one pile).