Reading and Writing Files

Every real program eventually needs to talk to the outside world, and the file system is one of the simplest ways to do that. Kotlin doesn’t invent a brand-new file API — it runs on the JVM and layers a set of concise, well-designed extension functions on top of Java’s java.io.File and its stream classes. Once you know these extensions, reading an entire file into a String or writing structured data to disk takes one line, while you still have full control — buffered streams, explicit character sets, and guaranteed resource cleanup — whenever you need it.

Overview / How It Works

java.io.File represents a path, not necessarily a file that exists. Writing File("data.txt") does no disk I/O at all — it just builds an object describing a location. Nothing touches the file system until you call a method like writeText, readText, exists, or delete. This matters because it means constructing a File can never throw, but almost every operation you perform on it can (a missing parent directory, no read permission, a full disk, and so on all throw java.io.IOException or a subclass such as FileNotFoundException at runtime).

Kotlin’s standard library (specifically the kotlin.io package, which is imported automatically in every Kotlin file) adds extension functions directly onto File: readText(), writeText(), appendText(), readLines(), forEachLine(), useLines(), bufferedReader(), and bufferedWriter(). All of them default to Charsets.UTF_8 but accept an explicit Charset argument if your data uses a different encoding.

There are two broad strategies for reading a file, and choosing the right one matters for memory usage:

  • Load everything at oncereadText() gives you the whole file as one String; readLines() gives you the whole file as a List<String>. Both are simple, but both hold the entire file in memory, which is fine for a small config file and a bad idea for a multi-gigabyte log.
  • Stream lazilyforEachLine { ... } and useLines { sequence -> ... } read one line at a time under the hood and never materialize the whole file in memory, so they scale to files far larger than available RAM.

Under the hood, all of these are convenience wrappers around a java.io.BufferedReader or BufferedWriter. When you call bufferedReader() or bufferedWriter() yourself, you get that raw stream, which is a real java.io.Closeable resource that must be closed when you’re done with it — otherwise the underlying file handle leaks. Kotlin provides a generic use { } function (an inline extension on Closeable) that runs your lambda and then calls close() in a finally block automatically, even if the lambda throws an exception. This is Kotlin’s equivalent of Java’s try-with-resources, and it is the idiomatic way to work with any stream you open manually.

Null safety shows up in a subtle place here: BufferedReader.readLine() is a Java method, and it returns null once it reaches the end of the file. Because it comes from Java rather than Kotlin, the compiler sees its return type as a platform type (written String!) rather than a fully-checked String?. That means Kotlin will happily let you call .uppercase() or any other member directly on the result without forcing a null check — and it will compile cleanly right up until the day the file happens to be empty and it throws a NullPointerException at runtime. Treat any raw Java API result as if it were nullable unless you know otherwise; the Common Mistakes section below shows exactly this scenario.

Syntax

The general shape of the core file functions looks like this:

File(path).writeText(text: String)                 // overwrite the file with text
File(path).appendText(text: String)                 // append text to the end
File(path).readText(): String                       // read the whole file as one String
File(path).readLines(): List<String>                // read the whole file as a list of lines
File(path).forEachLine(action: (String) -> Unit)     // stream through the file, line by line
File(path).bufferedReader(): BufferedReader          // manual, low-level reading
File(path).bufferedWriter(): BufferedWriter          // manual, low-level writing
Function Loads whole file? Best for
readText() / writeText() Yes Small files, config, single blobs of text
readLines() Yes Small-to-medium files you need as a List
forEachLine { } / useLines { } No (lazy) Large files, logs, streaming processing
bufferedReader() / bufferedWriter() No (manual) Custom parsing, mixed reads, fine-grained control

Examples

Example 1: Writing and reading a whole file

import java.io.File

fun main() {
    val file = File("greeting.txt")
    file.writeText("Hello, Kotlin file I/O!\n")

    val content = file.readText()
    print(content)

    file.delete()
}

Output:

Hello, Kotlin file I/O!

writeText opens the file, encodes the String to bytes using UTF-8, writes them, and closes the stream — all in one call. readText does the reverse: it opens the file, decodes the bytes back into a String, closes the stream, and returns the result. Because both are single expressions with no manual stream handling, this pattern is perfect for small, whole-file reads and writes like config files or scratch data.

Example 2: Streaming line by line and appending

import java.io.File

fun main() {
    val file = File("numbers.txt")
    file.writeText("10\n20\n30\n")
    file.appendText("40\n")

    var total = 0
    file.forEachLine { line ->
        total += line.trim().toInt()
    }
    println("Sum: $total")

    file.delete()
}

Output:

Sum: 100

appendText adds to the end of the file instead of overwriting it, so the file ends up containing four lines. forEachLine then streams through the file one line at a time, calling the trailing lambda for each line without ever holding the whole file in memory — the running total (var, since it must change on every line) accumulates as each line arrives.

Example 3: A realistic CSV workflow with data classes

import java.io.File

data class Product(val name: String, val price: Double)

fun parseLine(line: String): Product? {
    val parts = line.split(",")
    if (parts.size != 2) return null
    val price = parts[1].trim().toDoubleOrNull() ?: return null
    return Product(parts[0].trim(), price)
}

fun main() {
    val file = File("products.csv")
    file.bufferedWriter().use { writer ->
        writer.write("Keyboard,49.99")
        writer.newLine()
        writer.write("Mouse,19.99")
        writer.newLine()
        writer.write("Malformed Line")
        writer.newLine()
        writer.write("Monitor,199.99")
        writer.newLine()
    }

    val products = file.readLines().mapNotNull { parseLine(it) }
    for (product in products) {
        println("${product.name}: $${product.price}")
    }

    val totalValue = products.sumOf { it.price }
    println("Total: $${"%.2f".format(totalValue)}")

    file.delete()
}

Output:

Keyboard: $49.99
Mouse: $19.99
Monitor: $199.99
Total: $269.97

This ties several ideas together. bufferedWriter().use { } opens a manual stream and guarantees it’s closed once the block finishes. parseLine returns a nullable Product?, returning null for any malformed row instead of throwing; mapNotNull then quietly drops those nulls, so the deliberately broken “Malformed Line” row simply never appears in the output. Because Product is a data class, Kotlin generated its toString(), equals(), and hashCode() for free, and sumOf reduces the list to a single total.

How It Works Step by Step

When you run Example 3, the sequence is: (1) a File object is created — still no disk activity; (2) bufferedWriter() opens an actual output stream wrapped in a BufferedWriter, which batches your write calls in an internal buffer instead of hitting the disk on every call, for performance; (3) once the use block ends, close() flushes any buffered bytes and releases the file handle, whether or not an exception occurred inside the block; (4) readLines() opens a fresh input stream, reads every byte, decodes it as UTF-8, splits it on line boundaries into a List<String>, and closes the stream before returning; (5) each line is handed to parseLine, which either produces a Product or null; (6) mapNotNull walks the results and keeps only the non-null ones. Internally, forEachLine and useLines follow the same close-on-completion pattern: they call BufferedReader.readLine() in a loop until it returns null (signaling end-of-file), invoke your lambda for each non-null line, and close the reader in a finally block — which is exactly why readLine() is nullable in the first place.

Common Mistakes

Mistake 1: Opening a stream manually and forgetting to close it

val reader = File("data.txt").bufferedReader()
val firstLine = reader.readLine()
println(firstLine)
// reader.close() is never called here -- if readLine() throws,
// or you simply forget this line, the file handle stays open.

This leaks an operating-system file handle every time it runs. Under load, a program that repeats this pattern will eventually hit “too many open files” errors. The fix is to always wrap manually-opened streams in use { }, which closes the stream automatically even if an exception is thrown inside the block:

import java.io.File

fun main() {
    val file = File("data.txt")
    file.writeText("first line\nsecond line\n")

    file.bufferedReader().use { reader ->
        println(reader.readLine())
    }

    file.delete()
}

Output:

first line

Mistake 2: Trusting a Java platform type instead of checking for null

val reader = File("empty.txt").bufferedReader()
val firstLine = reader.readLine()
println(firstLine.uppercase())   // compiles! readLine() returns a Java
                                  // platform type, so Kotlin does not
                                  // force a null check here.
reader.close()

Because BufferedReader.readLine() comes straight from Java, Kotlin can’t guarantee it isn’t null, but it also doesn’t force you to check — the call compiles without a single warning. If the file happens to be empty, this throws a NullPointerException at runtime, summarized roughly as Exception in thread "main" java.lang.NullPointerException pointing at the .uppercase() call. Treat any Java-returned value as nullable and handle it explicitly with ?. and ?::

import java.io.File

fun main() {
    val file = File("empty.txt")
    file.writeText("")

    file.bufferedReader().use { reader ->
        val firstLine = reader.readLine()
        val message = firstLine?.uppercase() ?: "FILE WAS EMPTY"
        println(message)
    }

    file.delete()
}

Output:

FILE WAS EMPTY

Best Practices

  • Always wrap a manually-opened bufferedReader()/bufferedWriter() (or any Closeable) in use { } instead of calling close() yourself.
  • Prefer the high-level extensions (readText, writeText, forEachLine) over manual streams unless you specifically need finer control.
  • For large files, use forEachLine or useLines instead of readText/readLines so you never hold the whole file in memory at once.
  • Be explicit about the character set when the data isn’t UTF-8, e.g. file.readText(Charsets.ISO_8859_1).
  • Treat any value returned from a Java API (like readLine()) as potentially null, even when the Kotlin compiler doesn’t force a check.
  • Call file.parentFile?.mkdirs() before writing if the target directory might not exist yet — writeText does not create missing parent directories and will throw.
  • Catch IOException (or its subclass FileNotFoundException) around real file operations instead of assuming they always succeed.
  • Parse structured file formats into data classes rather than indexing into raw strings scattered throughout your code.

Practice Exercises

  • Write a program that creates a file diary.txt containing three lines of your choosing, reads it back, and prints the number of lines and the total character count.
  • Write a program that appends a new line such as "Event 1: signup" to app.log using an incrementing counter, then reads the whole file with forEachLine and prints every line prefixed with its line number.
  • Write a program that reads a CSV file of name,score pairs into a list of a data class, computes the average score, and prints only the names that scored above the average. Handle any malformed lines by skipping them instead of crashing.

Summary

  • File(path) only describes a location — no disk I/O happens until you call a method on it.
  • readText()/writeText()/appendText()/readLines() are simple, whole-file operations; forEachLine()/useLines() stream lazily for large files.
  • bufferedReader()/bufferedWriter() give manual control but must be closed — always wrap them in use { }.
  • use { } guarantees close() runs even if the block throws, just like Java’s try-with-resources.
  • Java APIs like BufferedReader.readLine() return platform types that bypass Kotlin’s null-safety checks at compile time — treat their results as nullable and handle them with ?./?:.
  • File operations can throw IOException/FileNotFoundException at runtime; don’t assume they always succeed.