1. Introduction
To be honest, println() is like talking to a program through a window: convenient as long as you are nearby and watching. But as soon as you need to save the result “for later” (a report, a log, a data export), the console becomes a poor archive: it gets cleared, it gets lost, and in general it is not obligated to be a source of truth. Writing to a file is a way to make the program’s output durable: you computed it today, opened it tomorrow, sent it to a colleague a week later.
In practice, writing text to a file is often needed even by the simplest console programs: save a report to "data/report.txt", keep an event journal in "data/app.log", dump a list of strings “as is” so that another tool (or you yourself) can read it later. And most importantly, it’s very easy to do if you understand two methods: writeText() and appendText().
In this lecture we write for Kotlin/JVM and use java.io.File (this is normal and expected practice on the JVM).
2. writeText and appendText: overwrite vs append
writeText(...): overwrite the whole file
When you first see writeText("..."), your brain often thinks: “Oh, write text to a file, great.” And then the classic moment happens: “Why does my file get emptied every time and only the last line is there?!” Because writeText overwrites the entire file. It behaves as if it says: “old contents — thanks, goodbye.”
This is very useful for reports, final results, and any files where you want to have the current version as a whole. For example: “generate an expense report” — you usually want the file to contain exactly one fresh report, not all reports from the last 300 runs.
A mini example: wrote some text, then read it back (reading is covered earlier; it’s here only for clarity).
import java.io.File
fun main() {
val out = File("data/out.txt")
out.parentFile?.mkdirs()
out.writeText("Hello, file!\n")
println(out.readText()) // Hello, file!
}
Notice "\n": a newline is not something that “just happens.” We will talk about this below, but it is already useful to remember: if you are writing “in lines,” you most often add the newline yourself.
A slightly more “real-life” example: overwrite the file twice and see what remains.
import java.io.File
fun main() {
val f = File("data/demo.txt")
f.parentFile?.mkdirs()
f.writeText("First\n")
f.writeText("Second\n")
println(f.readText()) // Second
}
Only "Second" will remain in the file. That is expected. If you need to “add to what already exists,” that’s a different method.
appendText(...): append to the end
appendText() is a way to tell a file: “Don’t touch what’s already there, just add this to the end.” It is perfect for logs, cumulative journals, simple action histories: “program started,” “user entered a command,” “export completed.”
It’s important to feel the difference at the intent level:
- A report is usually done via writeText(), because a report is “the current state as a whole.”
- A log is usually done via appendText(), because a log is “a history of events over time.”
A mini example: “a two-line log”:
import java.io.File
fun main() {
val log = File("data/app.log")
log.parentFile?.mkdirs()
log.appendText("Started\n")
log.appendText("Finished\n")
println(log.readText())
// Started
// Finished
}
Here you immediately see the second important idea: appendText does not add a newline automatically. It adds exactly the characters you gave it. If you forget "\n", the log will look like it was written by someone in a big hurry, all in one breath.
Newlines: how not to glue entries together
Newlines are simple, but sneaky. They are sneaky because the human eye expects that “two entries” will be on two lines. But the computer doesn’t care: you told it “append text,” so it appended text. Where the “line” is — that’s your agreement with the format.
You have two basic approaches:
- First: you always add "\n" at the end of each line entry when you do appendText(). This is the most straightforward, “honest” way.
- Second: you build the multi-line text in advance (for example, via joinToString("\n") or via """...""".trimIndent()), and then you do a single writeText(...).
A small table to avoid confusion:
| Scenario | What we do | Why |
|---|---|---|
| Write a final report all at once | |
easier to control the final format |
| Keep an event log | |
each event is a separate line |
| Write a list of lines | |
clear where the separators are |
Example: we have a list of lines and we want to neatly save it as a “file with lines.”
import java.io.File
fun main() {
val lines = listOf("milk", "bread", "coffee")
val text = lines.joinToString(separator = "\n") + "\n"
val file = File("data/list.txt")
file.parentFile?.mkdirs()
file.writeText(text)
println(file.readText())
// milk
// bread
// coffee
}
Why did I add + "\n" at the end? It’s not strictly required, but it’s a common habit: end a text file with a newline. Then, if you later do appendText("new item\n"), you won’t get the last line “stuck” to the new entry.
3. Preparing the directory before writing
This is one of the most typical “first hits” when working with files: you make a path like "data/reports/report.txt", call writeText(...), and get an error. The reason is often not the file, but the fact that the directory "data/reports" does not exist.
You can’t create a file in a folder that doesn’t exist. That sounds obvious, but a beginner’s brain for some reason is sure the computer will “figure it out” and create the folders. No, it won’t: you didn’t tell it to.
The correct minimal pattern before writing:
- create a File for the target file
- create the parent directory via parentFile?.mkdirs()
- do writeText or appendText
Example:
import java.io.File
fun main() {
val report = File("data/reports/today.txt")
report.parentFile?.mkdirs()
report.writeText("Report: OK\n")
println(report.absolutePath) // (will print the absolute path on your machine)
}
There is a neat nuance here: parentFile can be null if you write to a file without folders, for example File("out.txt"). That’s why we use the safe call ?..
Another useful point: mkdirs() creates a chain of directories. If you try mkdir() on "data/reports" and "data" doesn’t exist yet, mkdir() won’t handle it. In that sense, mkdirs() is almost always more convenient for “nested paths.”
4. Practical example: a report and a log for an expense tracker
So the topic doesn’t remain a set of separate commands, let’s weave file writing into a small fragment of our console application (let’s call it ExpenseTracker). Imagine that by this point in the course you already know how to store expenses in a collection and build a text report as a string. Now our task is to save this report to a file and, in parallel, keep a simple run log.
Let’s start with the model (minimal, so the example is self-contained):
data class Expense(val title: String, val amount: Int)
Now a function that builds a multi-line report text. We deliberately make this a separate function: a file is about writing, while a report is about formatting data. The separation “build text → write text” makes the program noticeably easier on the brain.
fun buildReport(expenses: List<Expense>): String {
val total = expenses.sumOf { it.amount }
val body = expenses.joinToString("\n") { "- ${it.title}: ${it.amount}" }
return "Expenses:\n$body\nTotal: $total"
}
Notice: here we form newlines inside the report string. This is fine, because a report is exactly a text document.
Now—write the report to a file via writeText(). And in the same logic we will prepare the directory.
import java.io.File
fun saveReport(reportText: String) {
val file = File("data/reports/expenses.txt")
file.parentFile?.mkdirs()
file.writeText(reportText + "\n")
}
And now—a log via appendText(). Let each run add a line to "data/app.log".
import java.io.File
fun appendLog(message: String) {
val log = File("data/app.log")
log.parentFile?.mkdirs()
log.appendText(message + "\n")
}
And a small main that shows the whole idea: create data, build a report, save it, write a log.
import java.io.File
fun main() {
val expenses = listOf(
Expense("Coffee", 250),
Expense("Lunch", 600)
)
val report = buildReport(expenses)
saveReport(report)
appendLog("Saved report with ${expenses.size} expenses")
println("Done") // Done
}
Discipline matters here: main does not turn into “spaghetti of file operations.” It calls clear functions: buildReport, saveReport, appendLog. Inside each function there is exactly one responsibility: prepare the path and write.
5. How to choose the method: a mini flow
When you write a real program, choosing between writeText and appendText can be almost mechanical. If you catch yourself thinking, “I want the current version of the file, so it contains exactly what was calculated now,” that’s writeText. If the thought is different: “I want to keep a history so new entries are appended,” that’s appendText.
For visual memory, here is a simple flowchart:
flowchart TD
A["Need to write text to a file"] --> B{"Is this the final result (the file should reflect the current state)?"}
B -->|Yes| C["writeText(...)"]
B -->|No, it's an event history| D["appendText(...)"]
C --> E["Don't forget to prepare the folder: parentFile?.mkdirs()"]
D --> E
E --> F["Don't forget the newline if writing 'in lines'"]
6. Common mistakes when writing text to a file
Mistake #1: using writeText where appendText is needed, and losing history.
The most painful case is logging. You want to see what happened across ten runs, but you only see the last event. The cause is almost always the same: out of habit you used writeText and overwrote the file each time. For logs, journals, and cumulative lists, you almost always need appendText.
Mistake #2: forgetting "\n" when appending and getting a “glued” file.
appendText("Started") and then appendText("Finished") will create "StartedFinished" in the file. The computer is not obligated to guess that you meant lines. If you write “line by line,” add the newline explicitly: appendText(message + "\n"). It seems like a small thing, but it is exactly these small things that later turn into “why is the log unreadable.”
Mistake #3: trying to write a file into a non-existent directory.
A path like "data/reports/report.txt" looks nice, but if the "data/reports" folder is not created, writing will fail. A good habit: before writeText/appendText, always do file.parentFile?.mkdirs(). This is especially important when the path is built dynamically and you don’t control which subfolders will be needed.
Mistake #4: mixing “building the text” and “writing the text” into one huge chunk of code.
When you do calculations, formatting, string work, folder checks, and writing all inside one block, the code becomes hard to read and easy to break. A much more robust style is where you first get val reportText = ..., and then in one clear step you do writeText(reportText). If the report format changes later, you don’t risk accidentally breaking the file part.
Mistake #5: not understanding that appendText appends “as is,” including extra spaces and accidental blank lines.
Sometimes a log ends up with a line that has extra spaces or an empty message, and then you “can’t figure out” where the junk came from. The reason is simple: you passed to appendText a string you didn’t normalize. Even for logs, it helps to agree in advance: either you clean the string (trim()), or you intentionally keep it as is, but then you are not surprised by the consequences.
Mistake #6: writing to the same file from different places in the program without a single rule.
If in one place you do writeText, and in another you do appendText to the same file, you’ll get chaotic behavior: sometimes the file is “reset,” sometimes it suddenly grows. Discipline helps: one file—one role. If it’s a report, it is overwritten. If it’s a log, it is appended. Mixing almost always leads to surprises.
GO TO FULL VERSION