I/O #

Input/Output (I/O) is the foundation of almost every useful application — reading configuration from files, writing logs, processing CSVs, or interacting with users in a terminal. Kotlin simplifies I/O significantly compared to Java: extension functions like readText(), writeText(), forEachLine(), and use {} replace the nested boilerplate you’d normally write with BufferedReader, FileReader, and manual try-finally. This article covers all aspects of I/O in Kotlin in depth — from the console, modern file operations, efficient large-file handling, directory operations, to asynchronous I/O with coroutines.

Console I/O #

Reading Input #

// readLine() — reads one line from stdin, returns String?
print("Enter your name: ")
val name = readLine()           // String? — can be null at EOF
println("Hello, $name!")

// Handle the possible null
val safeName = readLine() ?: "Anonymous"

// readLine() with type conversion
print("Enter your age: ")
val age = readLine()?.toIntOrNull()
if (age != null && age >= 0) {
    println("You are $age years old")
} else {
    println("Invalid age")
}

// Read multiple lines at once (until EOF or an empty line)
val lines = mutableListOf<String>()
println("Enter text (empty line to finish):")
while (true) {
    val input = readLine() ?: break
    if (input.isBlank()) break
    lines.add(input)
}
println("You entered ${lines.size} lines")

Writing Output #

// println — print with a newline
println("Hello, World!")

// print — print without a newline
print("Name: ")
print("Budi")
println()  // manual newline

// Formatted output
val price = 1_500_000.0
println("Price: Rp%,.0f".format(price))        // Price: Rp1,500,000
System.out.printf("Price: Rp%,.0f%n", price)   // Java printf alternative

// Output to stderr (for errors/logs)
System.err.println("Error: something went wrong")

// A simple console table
val header = "%-15s %5s %10s".format("Name", "Age", "City")
val separator = "-".repeat(35)
println(header)
println(separator)
listOf(
    Triple("Budi Santoso", 25, "Jakarta"),
    Triple("Sari Dewi", 28, "Bandung"),
    Triple("Ahmad Fauzi", 22, "Surabaya")
).forEach { (name, age, city) ->
    println("%-15s %5d %10s".format(name, age, city))
}

File I/O — Basic Operations #

Kotlin provides extension functions directly on the File class that make file operations much more concise than Java.

Reading Files #

import java.io.File

val file = File("data.txt")

// readText() — read the entire contents as a String (for small files)
val fullContents = file.readText()
println(fullContents)

// readText() with a specific encoding
val utf8Contents = file.readText(Charsets.UTF_8)

// readLines() — read all lines as a List<String>
val allLines = file.readLines()
println("Number of lines: ${allLines.size}")
allLines.forEachIndexed { i, line ->
    println("${i + 1}: $line")
}

// readBytes() — read as a ByteArray (for binary files)
val bytes = file.readBytes()
println("File size: ${bytes.size} bytes")

Writing Files #

val file = File("output.txt")

// writeText() — write a string (overwrites if it exists)
file.writeText("First line\nSecond line\n")

// appendText() — append to the end of the file (doesn't overwrite)
file.appendText("Third line\n")
file.appendText("Fourth line\n")

// writeBytes() — write a byte array (for binary files)
val binaryData = byteArrayOf(0x48, 0x65, 0x6C, 0x6C, 0x6F)  // "Hello"
File("binary.bin").writeBytes(binaryData)

// printWriter() — more flexible for complex formatting
File("report.txt").printWriter().use { writer ->
    writer.println("=== Sales Report ===")
    writer.println()
    listOf("Laptop: Rp15.000.000", "Mouse: Rp250.000").forEach {
        writer.println(it)
    }
    writer.printf("Total items: %d%n", 2)
}

// bufferedWriter() — efficient for many small write operations
File("log.txt").bufferedWriter().use { writer ->
    repeat(1000) { i ->
        writer.write("Log entry #$i")
        writer.newLine()
    }
}

Choosing the Right Read Method #

flowchart TD
    A{File size?} --> B{Small\n< 10MB}
    B -- Yes --> C{Need\nall lines?}
    C -- Yes --> D["readLines()\nList<String>"]
    C -- No --> E["readText()\nSingle String"]
    A --> F{Large\n> 10MB}
    F -- Yes --> G{Need to process\neach line?}
    G -- Yes --> H["forEachLine { }\nStreaming — memory-efficient"]
    G -- No --> I["useLines { }\nSequence — lazy"]
    A --> J{Binary file,\nnot text}
    J -- Yes --> K["readBytes() /\nInputStream"]

Handling Large Files Efficiently #

For large files, readText() and readLines() load the entire file into memory — dangerous for files hundreds of MB in size. Use a streaming approach:

val largeFile = File("large-data.csv")

// ANTI-PATTERN: load everything into memory
val allLines = largeFile.readLines()  // ✗ dangerous for large files!
allLines.forEach { processLine(it) }

// CORRECT: forEachLine — process line by line without loading into memory
largeFile.forEachLine { line ->
    processLine(line)
}

// CORRECT: useLines — provides a Sequence that can be filtered/mapped lazily
largeFile.useLines { sequence ->
    val totalSales = sequence
        .drop(1)  // skip the header
        .filter { it.isNotBlank() }
        .map { line ->
            val columns = line.split(",")
            columns.getOrNull(2)?.toDoubleOrNull() ?: 0.0
        }
        .sum()
    println("Total sales: Rp${\"%,.0f\".format(totalSales)}")
}
// The sequence is automatically closed after the useLines block ends

Reading CSV Line by Line #

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

fun readCSV(path: String): List<Product> {
    val result = mutableListOf<Product>()

    File(path).useLines { lines ->
        lines
            .drop(1)  // skip the header: id,name,price,stock
            .filter { it.isNotBlank() }
            .forEach { line ->
                val columns = line.split(",")
                runCatching {
                    result.add(Product(
                        id    = columns[0].trim().toInt(),
                        name  = columns[1].trim(),
                        price = columns[2].trim().toDouble(),
                        stock = columns[3].trim().toInt()
                    ))
                }.onFailure { e ->
                    System.err.println("Invalid line: '$line' — ${e.message}")
                }
            }
    }

    return result
}

The Path API — The Modern Way (Java NIO.2) #

java.nio.file.Path is a more powerful modern file API than java.io.File, with support for symlinks, permissions, and atomic operations.

import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardCopyOption

// Creating a Path
val filePath = Path.of("data/config.yaml")  // Java 11+
val oldPath = Paths.get("data", "config.yaml")  // Java 8+

// File info
println(Files.exists(filePath))           // true/false
println(Files.isDirectory(filePath))      // false
println(Files.isReadable(filePath))       // true/false
println(Files.size(filePath))             // size in bytes

// Reading with Path
val contents = Files.readString(filePath)      // Java 11+
val allLines = Files.readAllLines(filePath)

// Writing with Path
Files.writeString(filePath, "new content")  // Java 11+
Files.write(filePath, listOf("line1", "line2"))

// Copy a file
Files.copy(
    Path.of("source.txt"),
    Path.of("destination.txt"),
    StandardCopyOption.REPLACE_EXISTING
)

// Move/rename a file
Files.move(
    Path.of("old.txt"),
    Path.of("new.txt"),
    StandardCopyOption.REPLACE_EXISTING
)

// Delete a file
Files.deleteIfExists(Path.of("delete.txt"))

Directory Operations #

import java.io.File

val directory = File("project/data")

// Create a directory (including all missing parents)
directory.mkdirs()

// List directory contents
directory.listFiles()?.forEach { file ->
    val type = if (file.isDirectory) "[DIR]" else "[FILE]"
    println("$type ${file.name} (${file.length()} bytes)")
}

// Filter by extension
val kotlinFiles = directory.listFiles { file ->
    file.extension == "kt"
} ?: emptyArray()
println("Kotlin files: ${kotlinFiles.size}")

// Walk — recursive iteration of all subdirectories
File("src").walk()
    .filter { it.isFile && it.extension == "kt" }
    .forEach { file ->
        println("${file.relativeTo(File("src")).path}: ${file.length()} bytes")
    }

// Calculate the total directory size
val totalSize = File("project").walk()
    .filter { it.isFile }
    .sumOf { it.length() }
println("Total size: ${totalSize / 1024}KB")

// Delete a directory and all its contents
fun deleteRecursively(dir: File): Boolean {
    dir.walk().sortedDescending().forEach { it.delete() }
    return !dir.exists()
}

Serializing to Files — JSON and Properties #

Storing and Reading Properties #

import java.util.Properties

// Writing properties
val props = Properties()
props.setProperty("host", "localhost")
props.setProperty("port", "5432")
props.setProperty("dbName", "myapp")

File("config.properties").outputStream().use { out ->
    props.store(out, "Application Config")
}

// Reading properties
val config = Properties()
File("config.properties").inputStream().use { input ->
    config.load(input)
}

println(config.getProperty("host"))    // localhost
println(config.getProperty("port"))    // 5432
println(config.getProperty("dbName", "default"))  // myapp (with a default)

Manual Serialization to a JSON-like Format #

data class Config(
    val host: String,
    val port: Int,
    val debug: Boolean,
    val tags: List<String>
)

// Save to a file as a text representation
fun Config.saveTo(file: File) {
    file.printWriter().use { w ->
        w.println("{")
        w.println("  \"host\": \"$host\",")
        w.println("  \"port\": $port,")
        w.println("  \"debug\": $debug,")
        w.println("  \"tags\": ${tags.joinToString(", ", "[\"", "\"]")}")
        w.println("}")
    }
}

Stream I/O — Binary Operations #

For binary files (images, PDFs, ZIPs), use InputStream and OutputStream:

import java.io.File

// Copy a binary file with use{}
fun copyBinaryFile(source: String, destination: String) {
    File(source).inputStream().use { input ->
        File(destination).outputStream().use { output ->
            input.copyTo(output, bufferSize = 8192)  // 8KB buffer
        }
    }
}

// Read a resource from the classpath (for applications packaged in a JAR)
fun readResource(fileName: String): String {
    return Thread.currentThread()
        .contextClassLoader
        .getResourceAsStream(fileName)
        ?.bufferedReader()
        ?.use { it.readText() }
        ?: throw IllegalArgumentException("Resource not found: $fileName")
}

// Compress with GZip
fun compressFile(input: String, output: String) {
    java.util.zip.GZIPOutputStream(File(output).outputStream()).use { gzip ->
        File(input).inputStream().use { it.copyTo(gzip) }
    }
}

fun decompressFile(input: String, output: String) {
    java.util.zip.GZIPInputStream(File(input).inputStream()).use { gzip ->
        File(output).outputStream().use { gzip.copyTo(it) }
    }
}

Asynchronous I/O with Coroutines #

For applications that need non-blocking I/O, run I/O operations on Dispatchers.IO:

import kotlinx.coroutines.*
import java.io.File

// Read a file asynchronously
suspend fun readFileAsync(path: String): String {
    return withContext(Dispatchers.IO) {
        File(path).readText()
    }
}

// Write a file asynchronously
suspend fun writeFileAsync(path: String, content: String) {
    withContext(Dispatchers.IO) {
        File(path).writeText(content)
    }
}

// Process many files in parallel
suspend fun processAllFiles(directory: String): Map<String, Int> {
    return withContext(Dispatchers.IO) {
        File(directory).listFiles { f -> f.extension == "txt" }
            ?.map { file ->
                async {
                    file.name to file.readLines().size
                }
            }
            ?.awaitAll()
            ?.toMap()
            ?: emptyMap()
    }
}

fun main() = runBlocking {
    // Read several files in parallel
    val file1 = async { readFileAsync("data1.txt") }
    val file2 = async { readFileAsync("data2.txt") }
    val file3 = async { readFileAsync("data3.txt") }

    val results = listOf(file1, file2, file3).awaitAll()
    println("Successfully read ${results.size} files")

    // Process all .txt files in a directory
    val lineCounts = processAllFiles("data/")
    lineCounts.forEach { (name, count) ->
        println("$name: $count lines")
    }
}
Always run blocking file I/O operations (disk read/write) inside withContext(Dispatchers.IO). Dispatchers.IO uses a thread pool optimized for blocking operations — these threads can wait without wasting CPU threads from Dispatchers.Default.

I/O Error Handling #

import java.io.File
import java.io.IOException

// The idiomatic approach with runCatching
fun readFileSafe(path: String): Result<String> {
    return runCatching { File(path).readText() }
}

// Usage
val result = readFileSafe("config.txt")
result
    .onSuccess { contents -> println("Success: ${contents.length} characters") }
    .onFailure { e ->
        when (e) {
            is java.io.FileNotFoundException -> println("File not found: $path")
            is IOException -> println("I/O error: ${e.message}")
            else -> println("Unexpected error: ${e.message}")
        }
    }

// Or with getOrElse for a default value
val contents = readFileSafe("config.txt").getOrElse { "" }

// Validate before the operation
fun writeFileSafe(path: String, content: String): Boolean {
    val file = File(path)

    // Make sure the parent directory exists
    file.parentFile?.mkdirs()

    return runCatching {
        file.writeText(content)
        true
    }.getOrDefault(false)
}

Summary #

  • readText() for small files, forEachLine() or useLines() for large files — don’t load a file hundreds of MB into memory at once with readText(). Use streaming for files of uncertain size.
  • Always use {} for resourcesFile.bufferedReader().use { }, inputStream().use { }, printWriter().use { }. Kotlin guarantees resources are closed even when an exception occurs.
  • appendText() to add, writeText() to overwrite — choose deliberately; writeText() erases the old contents without warning.
  • mkdirs() not mkdir()mkdirs() creates all missing parent directories at once. mkdir() fails if a parent doesn’t exist.
  • File.walk() for recursive iteration — cleaner than manual loops. Filter with .filter { it.isFile } and process with .forEach { }.
  • Dispatchers.IO for I/O in coroutines — don’t do blocking file operations on Dispatchers.Default. Wrap them with withContext(Dispatchers.IO) { }.
  • runCatching { } for fallible I/O operations — cleaner than verbose try-catch. Use .onSuccess, .onFailure, .getOrElse, .getOrDefault.
  • The Path API (NIO.2) for advanced operationsFiles.copy(), Files.move(), Files.deleteIfExists() are more atomic and reliable than the old File API for critical file operations.

← Previous: Multithreading   Next: Sockets →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact