try, catch, and finally

When something goes wrong at runtime — dividing by zero, parsing a malformed number, an account with insufficient funds — a Kotlin program can either crash immediately or handle the problem gracefully. The try, catch, and finally block is Kotlin’s core mechanism for catching and recovering from these runtime errors. Unlike Java, Kotlin has no checked exceptions and no throws clause to satisfy, and — perhaps surprisingly to Java developers — try itself can be used as an expression that produces a value.

Overview: How try, catch, and finally work

Every runtime error in Kotlin is represented by an object that is a subtype of Throwable. Throwable has two main branches: Error, for serious problems the program usually shouldn’t try to recover from (like OutOfMemoryError), and Exception, for problems a program can reasonably catch and handle. Most exceptions you write and catch — ArithmeticException, NumberFormatException, IllegalStateException, and your own custom exceptions — extend RuntimeException, a subtype of Exception.

A key difference from Java: Kotlin has no checked exceptions. In Java, a method that throws IOException must declare throws IOException, and every caller is forced by the compiler to either catch it or re-declare it. Kotlin drops this entirely — every exception in Kotlin behaves like Java’s unchecked RuntimeException. You are never forced to catch anything, and functions never need a throws-style declaration. This is a deliberate design choice: the Kotlin team found checked exceptions in large Java codebases tended to produce either meaningless boilerplate (an empty catch (e: Exception) {}) or exceptions being wrapped and rethrown for no real benefit.

The other big difference is that try is an expression, not just a statement. Just like if and when, a try block can produce a value that you assign to a variable or return directly. The value of a try expression is the value of the last expression evaluated in whichever branch actually ran — the try body if nothing was thrown, or the matching catch body if an exception was caught. The finally block never contributes a value to the expression; its only job is to run cleanup code unconditionally.

Because throw is itself an expression with the special type Nothing (a type with no instances, meaning “this code never returns normally”), the compiler can slot a throw into almost any expression position — including as one branch of a try expression or after the Elvis operator (?:) — without breaking type checking.

Finally, Kotlin has no dedicated try-with-resources syntax like Java 7+. Instead, any Closeable resource (files, streams, sockets) exposes a standard library extension function, use { ... }, that runs a lambda and guarantees the resource is closed afterward, exception or not — it is the idiomatic replacement for a manual try/finally around close().

Syntax

try {
    // code that might throw an exception
} catch (e: SpecificExceptionType) {
    // handle a specific exception
} catch (e: AnotherExceptionType) {
    // handle a different exception
} finally {
    // always runs, exception or not
}
  • try — mandatory. Wraps the code that might throw.
  • catch (e: Type) — zero or more clauses. Each declares the exception type it handles; e is a normal local variable of that type inside the block. At least one catch or a finally must be present.
  • Catch order matters — clauses are checked top to bottom, and the first one whose type matches (via subtype checking) runs. Put more specific exception types before more general ones.
  • finally — optional. Runs after the try/catch has finished, whether an exception was thrown, caught, uncaught, or the block returned early. Used for cleanup: closing files, releasing locks, logging.

Examples

Example 1: Catching an arithmetic error

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

Output:

Error: / by zero
Program continues

Integer division by zero throws ArithmeticException at runtime (the compiler cannot catch this — it doesn’t know b is zero until the program runs). The catch block matches, prints the error, and — crucially — execution continues normally afterward instead of crashing the program. Note this only applies to integer division; floating-point division by zero produces Infinity or NaN instead of throwing.

Example 2: try as an expression

fun parseNumber(input: String): Int {
    return try {
        input.toInt()
    } catch (e: NumberFormatException) {
        println("Could not parse '$input', defaulting to 0")
        0
    }
}

fun main() {
    val a = parseNumber("42")
    val b = parseNumber("oops")
    println("a = $a, b = $b")
}

Output:

Could not parse 'oops', defaulting to 0
a = 42, b = 0

Here try is used directly after return. When input.toInt() succeeds, that Int becomes the value of the whole try expression. When it throws NumberFormatException, the catch block runs, and its last expression — the literal 0 — becomes the value instead. Both branches must produce a compatible type (here, Int) for this to compile.

Example 3: finally with a custom exception

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

fun withdraw(balance: Int, amount: Int): Int {
    if (amount > balance) {
        throw InsufficientFundsException("Cannot withdraw $amount, balance is only $balance")
    }
    return balance - amount
}

fun main() {
    val accounts = listOf(100 to 50, 100 to 150)
    for ((balance, amount) in accounts) {
        try {
            val newBalance = withdraw(balance, amount)
            println("Withdrew $amount, new balance: $newBalance")
        } catch (e: InsufficientFundsException) {
            println("Transaction failed: ${e.message}")
        } finally {
            println("Finished processing withdrawal of $amount")
        }
    }
}

Output:

Withdrew 50, new balance: 50
Finished processing withdrawal of 50
Transaction failed: Cannot withdraw 150, balance is only 100
Finished processing withdrawal of 150

InsufficientFundsException is a custom exception — any class extending Exception (or one of its subtypes) can be thrown and caught like a built-in one. Notice that finally runs on every iteration regardless of whether the withdrawal succeeded or failed — it always prints “Finished processing withdrawal” right after either the success message or the caught-exception message.

How it works step by step

When the runtime reaches a try block, this is the order of events:

  1. Statements inside try execute one by one.
  2. If a statement throws, the rest of the try block is skipped immediately, and the runtime looks for the first catch clause (checked top to bottom) whose declared type matches the thrown exception or one of its supertypes.
  3. If a matching catch is found, its body runs. Its last expression becomes the value of the whole try/catch if it’s being used as an expression.
  4. If no catch matches, the exception is left pending — but finally still runs before it propagates up the call stack to the caller.
  5. finally, if present, always runs last — after a successful try, after a caught exception, or after an uncaught one — even if the try or catch block contains a return.
  6. If finally itself contains a return, throw, or break/continue, that outcome replaces whatever the try or catch block was about to do — including silently discarding a pending exception. This surprising rule is explored in Common Mistakes below.

Common Mistakes

Mistake 1: Returning from finally silently swallows exceptions

Wrong:

fun riskyOperation(): Int {
    try {
        throw RuntimeException("Something went wrong")
    } finally {
        return -1
    }
}

fun main() {
    println("Result: ${riskyOperation()}")
}

Output:

Result: -1

This compiles and runs without ever mentioning the RuntimeException that was thrown. A return inside finally unconditionally exits the function, discarding any exception that was in flight — the caller has no idea anything went wrong. This is one of the most dangerous gotchas shared by Kotlin and Java: never return (or throw, or break/continue) from a finally block unless you deliberately intend to override the outcome.

Corrected — use finally only for cleanup, and let the exception propagate:

fun riskyOperationFixed(): Int {
    try {
        throw RuntimeException("Something went wrong")
    } finally {
        println("Cleaning up resources")
    }
}

fun main() {
    try {
        println("Result: ${riskyOperationFixed()}")
    } catch (e: RuntimeException) {
        println("Caught: ${e.message}")
    }
}

Output:

Cleaning up resources
Caught: Something went wrong

Mistake 2: Catching a general type before a specific one

Wrong — this fails to compile:

open class NetworkException(message: String) : Exception(message)
class TimeoutException(message: String) : NetworkException(message)

fun main() {
    try {
        throw TimeoutException("Request timed out after 30s")
    } catch (e: NetworkException) {
        println("Network problem: ${e.message}")
    } catch (e: TimeoutException) {
        println("Timeout: ${e.message}")
    }
}

Since TimeoutException is a subtype of NetworkException, the first catch clause matches every TimeoutException too, making the second clause unreachable. The Kotlin compiler rejects this outright with an error rather than silently letting the second block go dead. The fix is to always order catch clauses from most specific to most general:

open class NetworkException(message: String) : Exception(message)
class TimeoutException(message: String) : NetworkException(message)

fun main() {
    try {
        throw TimeoutException("Request timed out after 30s")
    } catch (e: TimeoutException) {
        println("Timeout: ${e.message}")
    } catch (e: NetworkException) {
        println("Network problem: ${e.message}")
    }
}

Output:

Timeout: Request timed out after 30s

Best Practices

  • Catch the most specific exception type you can meaningfully handle — avoid catching bare Exception or Throwable unless you are at a top-level boundary (like a server request handler) that must never crash.
  • Never leave a catch block empty. Swallowing an exception silently hides bugs; at minimum, log it.
  • Prefer Closeable.use { ... } over a manual try/finally when working with files, streams, or sockets — it closes the resource automatically and re-throws the original exception if closing also fails.
  • Never return, throw, or break out of a finally block — it silently discards whatever exception was propagating.
  • Don’t use exceptions for expected, normal control flow (like “value not found”); prefer returning a nullable type or a sealed result-style type for those cases, and reserve exceptions for genuinely exceptional situations.
  • Define custom exceptions by extending Exception (not Throwable directly) and always pass a descriptive message to the superclass constructor.
  • Keep the code inside a try block as small and focused as possible — wrapping an entire function body makes it hard to know which line could actually throw.

Practice Exercises

  • Write a function safeDivide(a: Int, b: Int): Int? that returns the result of a / b, or null instead of crashing when b is 0. Use try/catch and make the try block the returned expression.
  • Define a custom exception InvalidAgeException(message: String) and a function validateAge(age: Int): Int that throws it when age is negative or greater than 150, and otherwise returns age. Call it from main inside a try/catch/finally and print a friendly message in each block.
  • Without running it, predict the exact printed output of a function that has a try block which throws, a catch block that prints a message and does not return anything, and a finally block that also prints a message — then verify your prediction against Example 3 above.

Summary

  • try/catch/finally handles runtime errors represented by subtypes of Throwable; most exceptions you handle extend RuntimeException.
  • Kotlin has no checked exceptions — no throws declarations, and catching is always optional, never enforced by the compiler.
  • try can be used as an expression: its value is the last expression of whichever branch (try or catch) actually completed.
  • catch clauses are checked top to bottom; order them from most specific to most general or the compiler will reject unreachable clauses.
  • finally always runs — success, caught exception, or uncaught exception — making it the right place for cleanup, but a return inside it will silently swallow any pending exception.
  • Prefer Closeable.use { ... } over manual try/finally for resource cleanup.