Exceptions in Kotlin

An exception is an object that represents an error or unexpected condition that disrupts a program’s normal flow. When something goes wrong — dividing by zero, parsing invalid text, withdrawing more money than an account holds — a function can throw an exception instead of returning a bogus value, and code further up the call stack can catch it and decide what to do. Kotlin’s exception model will look familiar if you know Java, with one very important difference: Kotlin has no checked exceptions. Nothing forces you to declare what a function might throw or to catch it — which means the responsibility for knowing what can go wrong, and handling it deliberately, falls entirely on you.

This lesson covers how exceptions propagate through the call stack, how try works as both a statement and an expression, how to design your own exception types, and the mistakes that trip up even experienced developers.

Overview / How Exceptions Work

Every exception in Kotlin is an object whose class ultimately extends Throwable. The hierarchy splits into two broad branches: Error, reserved for serious problems like OutOfMemoryError that a program usually shouldn’t try to recover from, and Exception, the branch you actually work with. Common built-in subclasses include ArithmeticException, NumberFormatException, IndexOutOfBoundsException, NullPointerException, IllegalArgumentException, IllegalStateException, and ClassCastException.

In Java, exceptions are split into checked exceptions (which the compiler forces you to declare with throws or catch) and unchecked ones (which you can ignore and let crash the program). Kotlin does away with this distinction entirely: every exception, including ones inherited from Java libraries, is treated as unchecked. A function that can throw an exception compiles perfectly fine even if you never catch it — the compiler will not warn you. This is a deliberate design choice by JetBrains: in practice, checked exceptions in large Java codebases tend to produce boilerplate catch blocks that just wrap-and-rethrow or, worse, swallow errors silently. The tradeoff is that Kotlin puts more weight on documentation and discipline — you have to actually know that String.toInt() can throw NumberFormatException, because the type signature alone won’t tell you.

When code inside a try block throws, execution of that block stops immediately at the point of the throw. The Kotlin/JVM runtime then walks up through any enclosing catch clauses looking for one whose declared type matches the thrown exception’s class or one of its superclasses, checked in the order they’re written. If a match is found, that block runs and the exception is considered handled. If no catch in the current function matches, the exception propagates to the caller, and the caller’s caller, and so on — unwinding the stack — until something catches it or it reaches main, at which point the JVM prints a stack trace and the process terminates with a non-zero exit code. A finally block, if present, always runs during this unwinding, whether the try completed normally, threw, or is returning early — which makes it the right place for cleanup code such as closing a file or releasing a lock.

One more thing that trips up Java developers: Kotlin has no multi-catch syntax like Java’s catch (IOException | SQLException e). If two exception types need identical handling, you either write two catch blocks with the same body or catch a shared supertype.

Syntax

The general shape of exception handling in Kotlin looks like this:

try {
    // code that might throw an exception
} catch (e: SpecificException) {
    // handle SpecificException
} catch (e: AnotherException) {
    // handle AnotherException
} finally {
    // always runs, whether an exception occurred or not
}

throw SomeException("something went wrong")
Part Meaning
try Marks a block whose statements might throw; required before any catch or finally.
catch (e: Type) Handles an exception whose runtime class is Type or a subtype of it. Multiple catch blocks are checked top to bottom — put more specific types first.
finally Optional block that always executes after the try/catch, used for cleanup. Both try and finally are optional individually, but at least one of catch or finally must be present.
throw An expression (of type Nothing) that raises an exception and immediately transfers control to the nearest matching catch.
Throwable / Exception Throwable is the root of the hierarchy; Exception is the conventional base class for anything an application is expected to catch and handle.

A custom exception is just a class that extends Exception (or a more specific existing exception type) and forwards a message, and optionally a cause, to the superclass constructor:

class MyCustomException(message: String, cause: Throwable? = null) : Exception(message, cause)

Examples

Example 1: Catching a runtime exception

Integer division by zero doesn’t return infinity or NaN in Kotlin the way floating-point division does — it throws ArithmeticException at runtime, because the compiler cannot know the value of b in advance.

fun main() {
    val a = 10
    val b = 0
    try {
        val result = a / b
        println("Result: $result")
    } catch (e: ArithmeticException) {
        println("Error: ${e.message}")
    }
}

Output:

Error: / by zero

The division throws before result is ever assigned, so "Result: ..." never prints. Control jumps straight to the matching catch, which reads the exception’s message property.

Example 2: try as an expression, and finally

Unlike Java, Kotlin’s try is an expression: it evaluates to the last expression of whichever branch actually ran, so you can return its value directly. finally still runs in both cases.

fun divide(a: Int, b: Int): Int {
    return try {
        a / b
    } catch (e: ArithmeticException) {
        println("Caught: ${e.message}")
        -1
    } finally {
        println("Division attempt finished")
    }
}

fun main() {
    println(divide(10, 2))
    println(divide(10, 0))
}

Output:

Division attempt finished
5
Caught: / by zero
Division attempt finished
-1

For divide(10, 2), the try branch evaluates to 5 with no exception, finally prints its message, then 5 is returned and printed. For divide(10, 0), the division throws, the catch branch runs (printing its own message and evaluating to -1), finally still runs afterward, and -1 is returned.

Example 3: A custom exception for a real scenario

Built-in exceptions rarely describe your application’s own rules well. Here, InsufficientFundsException models a specific business error, and a data class holds the account state (its equals/toString/copy are auto-generated, though this example only needs its fields).

class InsufficientFundsException(message: String) : Exception(message)

data class Account(val owner: String, var balance: Double)

fun withdraw(account: Account, amount: Double) {
    if (amount > account.balance) {
        throw InsufficientFundsException(
            "Cannot withdraw $amount from balance of ${account.balance}"
        )
    }
    account.balance -= amount
}

fun main() {
    val account = Account("Alice", 100.0)
    val amounts = listOf(30.0, 50.0, 40.0)
    for (amount in amounts) {
        try {
            withdraw(account, amount)
            println("Withdrew $amount, new balance: ${account.balance}")
        } catch (e: InsufficientFundsException) {
            println("Transaction failed: ${e.message}")
        }
    }
}

Output:

Withdrew 30.0, new balance: 70.0
Withdrew 50.0, new balance: 20.0
Transaction failed: Cannot withdraw 40.0 from balance of 20.0

The first two withdrawals succeed and mutate balance (note that balance is declared var precisely because it needs to change; owner stays val). The third withdrawal of 40.0 exceeds the remaining 20.0, so withdraw throws, the loop’s catch handles it, and the loop continues instead of crashing the whole program.

Example 4: Automatic resource cleanup with use()

Java’s try-with-resources has a direct Kotlin equivalent: the use extension function, available on anything implementing Closeable. It runs the lambda and guarantees close() is called afterward, even if an exception is thrown inside — without you writing a finally block yourself.

import java.io.StringReader

fun main() {
    val reader = StringReader("Hello, Kotlin!")
    reader.use {
        val buffer = CharArray(5)
        it.read(buffer)
        println(String(buffer))
    }
}

Output:

Hello

read fills the 5-character buffer with the first five characters of the string, and use closes the StringReader once the lambda finishes.

How It Works Step by Step

  • The JVM executes the try block’s statements in order, exactly as if the try weren’t there, until either the block finishes or a statement throws.
  • The moment something throws, an exception object is created (capturing its message, cause, and a stack trace at that instant) and all remaining statements in the try block are skipped.
  • The runtime compares the exception’s actual class against each catch clause, top to bottom, looking for the first one whose declared type is the same class or a supertype of the thrown exception.
  • If a match is found, that catch block runs to completion (or throws itself), and the exception is considered handled — execution then continues after the whole try/catch/finally.
  • If no catch in the current function matches, the exception propagates to the caller’s stack frame, and the search repeats there, unwinding one frame at a time.
  • Regardless of whether the exception was caught locally or is still propagating, any finally block belonging to a try the unwinding passes through executes before moving on.
  • If the exception is never caught anywhere, it reaches the top of the call stack (main), the JVM prints the exception type, message, and stack trace to standard error, and the process exits with a non-zero status.

Common Mistakes

Mistake 1: Catching too broadly and swallowing errors

Catching Exception and doing nothing with it hides bugs instead of fixing them — the program looks like it worked, but silently didn’t.

// Wrong: catches everything and throws the information away
fun processOrder(orderId: String) {
    try {
        val id = orderId.toInt()
        submitOrder(id)
    } catch (e: Exception) {
        // swallowed - no logging, no handling
    }
}

Catch the specific exception you actually expect, and do something meaningful with it:

fun submitOrder(id: Int) {
    println("Order $id submitted")
}

fun processOrder(orderId: String) {
    try {
        val id = orderId.toInt()
        submitOrder(id)
    } catch (e: NumberFormatException) {
        println("Could not submit order: invalid id '$orderId'")
    }
}

fun main() {
    processOrder("42")
    processOrder("abc")
}

Output:

Order 42 submitted
Could not submit order: invalid id 'abc'

Mistake 2: Using exceptions for expected, ordinary control flow

Throwing captures a stack trace and is relatively expensive; using try/catch to check something that is a completely normal, expected outcome (like "is this string a number?") is both slower and less idiomatic than Kotlin’s null-returning alternatives.

fun isNumeric(s: String): Boolean {
    return try {
        s.toInt()
        true
    } catch (e: NumberFormatException) {
        false
    }
}

fun main() {
    val values = listOf("10", "abc", "42")
    for (v in values) {
        println("$v numeric? ${isNumeric(v)}")
    }
}

Prefer toIntOrNull(), which returns null instead of throwing when parsing fails — no exception involved for an entirely expected case:

fun main() {
    val values = listOf("10", "abc", "42")
    for (v in values) {
        val isNumeric = v.toIntOrNull() != null
        println("$v numeric? $isNumeric")
    }
}

Output (both versions):

10 numeric? true
abc numeric? false
42 numeric? true

Mistake 3: Returning from finally

A return inside finally silently discards any exception (or return value) from the try/catch it belongs to — the exception simply vanishes, which is one of the most confusing bugs to track down.

// Wrong: the IllegalStateException never reaches the caller
fun riskyCalculation(): Int {
    try {
        throw IllegalStateException("Calculation failed")
    } finally {
        return -1
    }
}

Keep finally for side effects only (logging, closing resources) and let the exception propagate normally:

fun compute(): Int {
    try {
        throw IllegalStateException("Calculation failed")
    } finally {
        println("Cleanup ran, but the exception is not swallowed")
    }
}

fun main() {
    try {
        val result = compute()
        println("Result: $result")
    } catch (e: IllegalStateException) {
        println("Caught: ${e.message}")
    }
}

Output:

Cleanup ran, but the exception is not swallowed
Caught: Calculation failed

Best Practices

  • Catch the most specific exception type you can, ordered from most to least specific when using multiple catch blocks.
  • Never leave a catch block empty — at minimum log the exception, even if you decide the situation is safe to ignore.
  • Reserve exceptions for genuinely exceptional, unexpected situations; use nullable types (toIntOrNull(), ?., ?:) for outcomes that are a normal part of your logic.
  • Never put a return inside a finally block — it silently swallows whatever exception or value the try/catch produced.
  • Give custom exceptions a clear, specific message, and forward an original cause when wrapping a lower-level exception so the root cause isn’t lost.
  • Use use { } instead of manual try/finally for anything that implements Closeable, such as file or network streams.
  • Remember that Kotlin has no checked exceptions: read the documentation (or source) of library functions you call, since the compiler won’t warn you about what they can throw.
  • Avoid !! as a substitute for exception handling — a NullPointerException from !! almost always means a null-safety bug, not a condition worth catching.

Practice Exercises

  • Write a function parseAge(input: String): Int that parses input as an integer and throws a custom InvalidAgeException if the string isn’t a valid number or if the parsed value is negative. Call it in a loop over several test strings (including a valid age, a negative number, and non-numeric text) inside a try/catch that prints either the parsed age or the error message.
  • Write a simple MutableList<Int>-backed stack with a pop() function that throws a custom EmptyStackAccessException when called on an empty stack. Push three values, pop four times, and catch the exception on the fourth call instead of letting the program crash.
  • Predict the output before running it: a function wraps a throw in a try with a finally that only printlns (no return), called from a main that catches the exception. Write down the order in which you expect lines to print, then check it against Example 3’s structure above.

Summary

  • Kotlin exceptions are all unchecked — there’s no throws declaration and no compiler enforcement of catching, unlike Java.
  • try is an expression in Kotlin: it can evaluate to a value taken from whichever branch (try or catch) actually ran.
  • catch clauses are checked in order and match on the exception’s class or any of its supertypes; put specific types before general ones.
  • finally always runs during stack unwinding, whether or not an exception occurred — but a return inside it silently discards the original exception or value.
  • Custom exceptions are ordinary classes extending Exception (or a more specific type), forwarding a message and optional cause to the superclass.
  • Prefer nullable-returning alternatives (toIntOrNull(), ?., ?:) over exceptions for expected, non-exceptional outcomes.
  • use { } gives you guaranteed resource cleanup for any Closeable, without writing your own finally block.