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) insidewithContext(Dispatchers.IO).Dispatchers.IOuses a thread pool optimized for blocking operations — these threads can wait without wasting CPU threads fromDispatchers.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()oruseLines()for large files — don’t load a file hundreds of MB into memory at once withreadText(). Use streaming for files of uncertain size.- Always
use {}for resources —File.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()notmkdir()—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.IOfor I/O in coroutines — don’t do blocking file operations onDispatchers.Default. Wrap them withwithContext(Dispatchers.IO) { }.runCatching { }for fallible I/O operations — cleaner than verbose try-catch. Use.onSuccess,.onFailure,.getOrElse,.getOrDefault.- The
PathAPI (NIO.2) for advanced operations —Files.copy(),Files.move(),Files.deleteIfExists()are more atomic and reliable than the oldFileAPI for critical file operations.