1. Introduction
When people say “read a file”, beginners often imagine something like: “the program opened the file like Notepad and saw the letters”. In reality, a program always does more boring (and honest) work: it takes the file and turns its contents into data inside your application — most often into a String or a List<String>.
What’s convenient about text files in Kotlin/JVM is that the standard library and File provide ready-made methods “out of the box”, and we can choose a reading style for the task: read everything at once, read lines into memory, or process the file line by line while using almost no memory.
It’s important to lock in one idea: by choosing a reading method, you are choosing a data shape. And the data shape strongly affects how easy it will be to write code next and how painful it will be for the computer (in memory and time).
How to choose a reading method
The choice between readText, readLines, forEachLine is not a question of “which method is correct”, but a question of “what data shape do I need next” and “how large is the file”.
So you don’t have to guess every time, it helps to keep a comparison in front of your eyes.
Selection table
| Method | What it returns | Memory | When it’s convenient | Typical “aftertaste code” |
|---|---|---|---|---|
|
|
reads everything at once | the file is “one unit of meaning”: a template, a small text, a config | string operations: trim, split, replace, substring search |
|
|
reads everything at once | the file logically consists of lines, and you want collection operations | map/filter/count/groupBy, sorting, line analysis |
|
returns nothing directly (you accumulate the result yourself) | line by line | the file is large, or the result can be computed “on the fly” | counters, search, max/min, accumulation into a MutableList |
There’s an important psychological point here: readText() and readLines() give you a ready result immediately, while forEachLine makes you think “how will I accumulate the answer”. That’s normal: line-by-line processing is almost always a bit more “algorithmic”.
Selection flowchart
Sometimes it’s easier to remember not a table, but a small decision route:
flowchart TD
A[Need to read text from a file] --> B{Need the whole file as a single string?}
B -->|Yes| C["readText()"]
B -->|No| D{Need a list of lines in memory?}
D -->|Yes| E["readLines()"]
D -->|No, can process on the fly| F["forEachLine { ... }"]
If you catch yourself thinking “I’m uncomfortable with forEachLine, I want a list” — that’s an honest signal that you might actually need readLines(). Just remember the cost: a list = memory.
A nuance about output performance
Sometimes a file is read in order to print many lines to the console afterward. That’s where the classic trap appears: “I’ll do println for each line in a loop”. Technically it works, but with large amounts of output it can be slow. In the Kotlin community, people often suggest collecting the output and printing it with a single println via joinToString("\n") when there are many lines.
Today we’re not turning the lecture into an optimization race, but you can start building the habit of “not printing a million println without a reason” right now.
2. readText() — when you need the whole file
Sometimes a file is just “one thing”: an email template, a small config, a saved report, a note text. In those cases, it’s easiest to get it as a single string. For that, File has the readText() method.
After readText() you have a String, and you can do everything with the string that you already know how to do: length, trim(), split(), replace(), search for substrings, build reports with StringBuilder, and so on.
Minimal example of readText()
First, the smallest example to feel the mechanics:
import java.io.File
fun main() {
val text: String = File("data/input.txt").readText()
println("chars=${text.length}") // chars=...
}
If the file is small, this approach is almost always the most “brain-friendly”: one variable, one type, then pure string work.
Example: read → normalize → use
Often you want to “clean up” the text from a file first: remove extra whitespace at the edges, check that it’s not empty.
import java.io.File
fun main() {
val raw = File("data/title.txt").readText()
val title = raw.trim()
println("title='$title'") // title='...'
}
Note: trim() here is not “cosmetics”, but real protection against surprises like “there was an accidental newline at the end of the file”.
When readText() is a bad idea
readText() reads the entire file into memory as one string. If the file is large (for example, a log hundreds of megabytes long), you first spend memory on a huge String, and then you also spend time on operations over that string. That’s when you want line-by-line processing.
A simple life heuristic is: if you can open the file in Notepad and it doesn’t make Notepad ponder the meaning of life, readText() is usually fine. If Notepad freezes and you have time to make tea — better don’t.
3. readLines() — when you need a list of lines
The next typical situation: a file is a set of lines, and each line means something. For example, a shopping list, a list of commands, a list of notes, a list of expenses. In such tasks it’s convenient to immediately get a List<String>, where each element is a separate line of the file. For that, there is readLines().
readLines() is good because it turns the file into a familiar collection, and with collections we already can do a lot: map, filter, count, firstOrNull, sortedBy, grouping, and so on.
Minimal example of readLines()
import java.io.File
fun main() {
val lines: List<String> = File("data/input.txt").readLines()
println("lines=${lines.size}") // lines=...
}
Now you don’t have a “hazy string with newlines”, but concrete list elements. This often makes code simpler and more readable.
Example: find the first non-empty line
A very typical scenario: the file may contain empty lines, but the first “useful” line matters to us.
import java.io.File
fun main() {
val firstNonBlank = File("data/input.txt")
.readLines()
.firstOrNull { it.isNotBlank() }
println(firstNonBlank) // for example: Hello
}
An important habit here: we use firstOrNull, not first. Because the file might turn out to be empty, and then first() will throw you an “exception party”.
Example: read expenses from a text file
Let’s imagine that by this point in the course we already have an expense model (it appeared when we moved on to classes and data class). We’re not discussing “ideal formats” yet; we’ll use a simple line format:
amount|category|comment
For example:
350|food|coffee
1200|transport|metro card
Then reading into memory via readLines() looks natural: we want to get a list of lines, and then parse each line into an object.
import java.io.File
data class Expense(val amount: Int, val category: String, val comment: String)
fun parseExpenseOrNull(line: String): Expense? {
val parts = line.split("|")
val amount = parts.getOrNull(0)?.toIntOrNull() ?: return null
val category = parts.getOrNull(1)?.trim().orEmpty()
val comment = parts.getOrNull(2)?.trim().orEmpty()
return Expense(amount, category, comment)
}
Now read the file and turn the lines into expenses:
import java.io.File
fun main() {
val lines = File("data/expenses.txt").readLines()
val expenses = lines.mapNotNull { parseExpenseOrNull(it) }
println("loaded=${expenses.size}") // loaded=...
}
Why is readLines() convenient here? Because we want a collection and we want to apply a “collection style” (mapNotNull) instead of manual loops and temporary variables.
Limitation of readLines()
Like readText(), readLines() reads the file entirely, just not into one string, but into a list of lines. So in terms of memory it’s still “load everything at once”. For small and medium files — great. For very large ones — better switch to line-by-line processing.
4. forEachLine { ... } — line-by-line processing
Sometimes we don’t need to keep the whole file. We want to go through it and count, find, filter, but without creating a huge list of lines. For that, File has the line-by-line method forEachLine { line -> ... }. The idea is simple: Kotlin reads the file line by line and passes each line to your lambda.
This approach is often used for tasks similar to processing logs or large input data. In Kotlin materials it’s mentioned as a handy tool specifically for reading files line by line.
Minimal example of forEachLine
import java.io.File
fun main() {
var nonBlank = 0
File("data/input.txt").forEachLine { line ->
if (line.isNotBlank()) nonBlank++
}
println("nonBlankLines=$nonBlank") // nonBlankLines=...
}
Notice: we did not create a List<String>. We just walked through the file and counted.
Example: find the maximum expense amount without storing lines
If the expense file can be large, and we need, for example, the maximum by amount, then forEachLine is a good fit:
import java.io.File
fun main() {
var maxAmount = 0
File("data/expenses.txt").forEachLine { line ->
val expense = parseExpenseOrNull(line) ?: return@forEachLine
if (expense.amount > maxAmount) maxAmount = expense.amount
}
println("maxAmount=$maxAmount") // maxAmount=...
}
Here we used return@forEachLine. This is not a “return from main”, but “skip the current line”. We do this if the line is broken or empty.
Why forEachLine is often safer for memory
forEachLine lets you work with a file like a “tape”: a line arrives → you process it → move on. Memory doesn’t inflate because of a list of lines. This is especially nice when you don’t know in advance how big a file you’ll be handed (life loves sudden surprises).
5. Mini-assembly: read a file and prepare data for the app
Now we’ll make a neat reading “skeleton” for our console application (loosely, an expense tracker), but in a way that stays simple and doesn’t step into topics from the next lecture (writing) and the following days (reliable I/O).
The idea: we want one function that takes a File and returns a list of valid expenses. That’s it. No magic.
Reading function via readLines()
import java.io.File
fun loadExpenses(file: File): List<Expense> {
if (!file.exists() || !file.isFile) return emptyList()
return file.readLines()
.mapNotNull { line -> parseExpenseOrNull(line.trim()) }
}
It reads almost like plain English: “if the file doesn’t exist — empty; otherwise read lines and parse”.
Alternative via forEachLine
import java.io.File
fun loadExpensesStreaming(file: File): List<Expense> {
if (!file.exists() || !file.isFile) return emptyList()
val result = mutableListOf<Expense>()
file.forEachLine { line ->
val e = parseExpenseOrNull(line.trim()) ?: return@forEachLine
result.add(e)
}
return result
}
Yes, there’s a bit more code. But it doesn’t require holding a List<String> of all lines. We carefully accumulate only the “good” records.
6. Common mistakes when reading text from files
Mistake #1: choosing readText() “out of habit” and then being surprised that the program eats memory.
readText() is a great method, but it literally means “read EVERYTHING”. If the file suddenly turns out to be large, you’ll get a huge String, and then heavy operations on it. In such cases, it’s useful to switch to readLines() or forEachLine() and think in terms of lines.
Mistake #2: confusing “a line in the file” with “the entire file as a String”.
After readText() you have one big string that contains "\n". Beginners sometimes expect that “if the file has many lines, the variable will somehow be line-based too”. It won’t. If you need to work with lines next, it’s faster (and clearer) to take readLines() right away.
Mistake #3: not normalizing lines before parsing.
Even a “clean” file often has trailing spaces, empty lines, extra newlines. If you immediately do split("|") without trim() and without checking isBlank(), you’ll get weird bugs that look like “it worked yesterday, today it doesn’t”. Minimal protection is line.trim() and careful toIntOrNull().
Mistake #4: forgetting that the file may be missing and the path may point to a directory.
If you immediately do File("...").readLines(), and the file isn’t found, the program will crash with an exception. We already know basic checks exists() and isFile — and this is exactly the case when they truly save nerves. Especially in learning projects, where directories and files keep moving around.
Mistake #5: trying to “return a value” from forEachLine using a normal return.
Inside the forEachLine lambda, a regular return may behave differently than a beginner expects (the rules for returns from lambdas matter there). If you want to skip a line — use return@forEachLine. If you want to accumulate a result — create a variable outside and update it inside the lambda.
Mistake #6: mixing reading and complex business logic in one huge block.
When inside forEachLine or after readLines() you immediately get a “sheet” of conditions, counts, and report formatting, the code becomes hard to read and painful to debug. It’s much calmer to keep the style: “read → got a data structure → then processed”, even if the data structure is still simple for now.