runCatching vs. try-catch in Kotlin: A Comprehensive Error Handling Comparison

Ioannis Anifantakis on 2025-04-02

runCatching vs. try-catch in Kotlin: A Comprehensive Error Handling Comparison

Image Source: ChatGPT 4o

Introduction

When you are writing Kotlin code, you will eventually come across different ways to handle exceptions or, in general, handle functions that may fail. One common approach is to use try-catch blocks—just as you might do in many other programming languages like Java.

However, Kotlin has introduced some other convenient methods that can make exception handling more flexible and concise. One of these methods is runCatching.

In this article, we will explore Kotlin’s runCatching in detail. We will look at how it compares to classic try-catch blocks, and whether it brings any real benefits or if it is simply a “new style” of doing the same thing. This discussion should help you decide if runCatching is right for your coding style or project requirements.

1. A Quick Look at Error Handling in Kotlin

Before we talk about runCatching, let’s make sure we understand the basics of error handling in Kotlin. Error handling usually revolves around something going wrong in your code—an exception. In Kotlin, you might have code that could throw an exception, such as calling a function that might fail under certain circumstances.

Traditionally, the way to handle an exception in Kotlin is the same way it’s done in Java:

try {
    // Code that might throw an exception
} catch (e: Exception) {
    // Handle the exception
} finally {
    // Code that should run whether or not an exception is thrown
}

1.1 The Purpose of try-catch

  1. try block: You place the code that you suspect might throw an exception inside try.
  2. catch block: If an exception of a certain type is thrown, the program flow jumps here to deal with the error. You might choose to log it, rethrow it, or handle it in some other way.
  3. finally block: This block is optional. Code inside finally runs no matter what happens in the try or catch blocks (unless you kill the application entirely). Often this is used for cleaning up resources, like closing a database connection.

Kotlin provides some higher-order functions for working with operations that might fail, among them runCatching. The name gives us a clue: “run” refers to executing some code, and “catching” refers to catching exceptions that might come out of that code.

So basically, runCatching is a function that executes a given block of code and wraps the result. If your block fails, runCatching will capture the exception; if it succeeds, it will capture the successful result.

You can think of runCatching as Kotlin’s approach to more functional-style error handling. It returns a Result<T>, which is essentially a sealed class (in many Kotlin versions) that can hold either a successful outcome (Success) or a failure outcome (Failure).

2.1 The Basics of runCatching

Here is a simple example of runCatching in action:

val result: Result<Int> = runCatching {
    // Some code that might throw an exception
    10 / 2
}

In this code, if the expression 10 / 2 is successful, result will hold Success(5). If it failed with an exception, result would hold Failure(e) for some e.

The beauty is that result is always a Result<Int>, so you don’t need separate blocks in the immediate code. You can then work with result to handle success or failure in a more declarative style.

2.2 A More Involved Example

Let’s try something closer to our file reading scenario. Instead of try-catch-finally, we can write:

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

fun readFileUsingRunCatching(filePath: String): Result<String> {
    return runCatching {
        val file = File(filePath)
        file.readText()
    }
}

fun main() {
    val result = readFileUsingRunCatching("/path/to/file.txt")
    result
      .onSuccess { content   -> println("File content: $content") }
      .onFailure { exception -> println("Failed to read file: ${exception.message}") }
}

In this version, the readFileUsingRunCatching function returns a Result<String>. If reading the file works, the result will be Success containing the file’s content. If reading fails, the result will be Failure containing the exception.

Then, in the main function, we can deal with success or failure in a chain of calls:

3. Comparing runCatching to try-catch

Now that we have a rough idea of how runCatching works, let’s address the main question: Is this just a “new style” of doing the same old thing, or does runCatching provide added benefits?

3.1 Similarities

  1. They both capture exceptions: Whether you use try-catch or runCatching, you are still making sure that if something goes wrong, you handle it in some manner.
  2. They do not eliminate the possibility of exceptions: Even though runCatching returns a Result, you still need to manage the fact that some part of your code might fail.
  3. They both can handle cleanup: You can replicate some of the logic you might place in a finally block by chaining calls on the Result object (like using onFailure to free resources). Alternatively, you can place cleanup inside the runCatching block if needed. It just requires a slightly different organization.

1. Return type:

With this approach you chain operations in a pipeline rather than controlling flow with blocks.

4. Verbosity:

runCatching returns a Result<T>. This Result class in Kotlin can store either a value (success) or an exception (failure). It’s designed to help you handle these outcomes in a single, unified way rather than scattering your logic.

4.1 Result<T> Methods

4.2 Example of Chaining

Let’s say you have a function that returns a user object from a server. We want to process that user object if it’s valid. Otherwise, we log the error and return a fallback user. If something happens even during the recover part, the getOrNull will return null user.

val userResult = runCatching { fetchUserFromServer() }
    .map { user ->
        // Transform the user, e.g., trimming fields or validating
        user.copy(name = user.name.trim())
    }
    .recover { exception ->
        // If something went wrong, recover with a fallback user
        println("Error fetching user: ${exception.message}")
        User(id = -1, name = "Guest")
    }

val user = userResult.getOrNull()
println("User: $user")

This style can help keep your logic in one continuous flow, without multiple nested blocks. After the runCatching, we transform on success, and if it fails, we recover with a fallback user.

Let’s see how it would be using onSuccess and onFailure:

val userResult = runCatching {
    fetchUserFromServer()
}.map { user ->
    user.copy(name = user.name.trim())
}

userResult
    .onSuccess { user ->
        // If everything was successful, print the trimmed user
        println("User: $user")
    }
    .onFailure { exception ->
        // If something went wrong, log and print the Guest user
        println("Error fetching user: ${exception.message}")
        val fallback = User(id = -1, name = "Guest")
        println("User: $fallback")
    }

and the same using try/catch/finally blocks:

fun main() {
    var user: User? = null
    try {
        val fetchedUser = fetchUserFromServer()
        val trimmedUser = fetchedUser.copy(name = fetchedUser.name.trim())
        user = trimmedUser  // If everything goes well, we store the trimmed user
    } catch (exception: Exception) {
        println("Error fetching user: ${exception.message}")
        user = User(id = -1, name = "Guest")  // Fallback user on failure
    } finally {
        println("User: $user")
    }
}

5. Handling Cleanup and Resource Management

One thing that often comes up with try-catch-finally is resource management. For example, if you open a file or a network connection in the try block, you may want to ensure it gets closed in finally, no matter what. How does that translate to runCatching?

In many cases, Kotlin has its own patterns for handling resources, such as the use function for closeable resources. This can handle cleanup automatically. But if you do want some custom “cleanup” logic, you have options:

1. Cleanup Only on Failure (Inside onFailure)

If you only need to clean up the resource when something goes wrong, you can do it in onFailure. Let’s pretend we have a file stream that we only need to discard or handle specially if reading fails:

import java.io.FileInputStream
import java.io.IOException

fun readWithCleanupOnFailure(filePath: String) {
    val stream = FileInputStream(filePath)

    runCatching {
        // Potentially risky operation
        val data = stream.readBytes()
        println("Read ${data.size} bytes")
    }.onFailure { exception ->
        println("Something went wrong: ${exception.message}")
        // If we only want to close the stream on failure:
        try {
            stream.close()
        } catch (closeException: IOException) {
            println("Error closing stream on failure: ${closeException.message}")
        }
    }
    // If no exception, the stream remains open after this function
    // (only use this approach if that’s truly your intent)
}

2. Cleanup No Matter What

2A. Using Kotlin’s use Extension

Kotlin’s use function is the most idiomatic pattern for closing resources that implement Closeable. It automatically closes the resource when the lambda exits, regardless of success or failure:

import java.io.FileInputStream
import java.io.IOException

fun readWithUse(filePath: String) {
    runCatching {
        FileInputStream(filePath).use { stream ->
            val data = stream.readBytes()
            println("Read ${data.size} bytes")
            // The stream will be closed automatically
        }
    }.onFailure { exception ->
        println("Something went wrong: ${exception.message}")
    }
}

If you must open the resource before runCatching and then close it unconditionally afterwards:

import java.io.FileInputStream
import java.io.IOException

fun readWithSeparateClose(filePath: String) {
    val stream = FileInputStream(filePath)

    val result = runCatching {
        val data = stream.readBytes()
        println("Read ${data.size} bytes")
    }

    // Perform unconditional cleanup
    try {
        stream.close()
    } catch (e: IOException) {
        println("Failed to close stream: ${e.message}")
    }

    // Now handle success or failure
    result.onFailure {
        println("Operation failed: ${it.message}")
    }.onSuccess {
        println("Operation succeeded.")
    }
}

2C. Cleanup using also {…}

import java.io.FileInputStream
import java.io.IOException

fun readWithAlso(filePath: String) {
    val stream = FileInputStream(filePath)
    
    val result = runCatching {
        // Potentially risky operation
        val data = stream.readBytes()
        println("Read ${data.size} bytes")
        // Return something or do more processing...
    }.also {
        // This block runs whether the above succeeded or failed.
        // Perfect for unconditional cleanup or logging:
        try {
            stream.close()
        } catch (e: IOException) {
            println("Error closing stream: ${e.message}")
        }
        println("Cleanup or logging done here.")
    }

    result
        .onFailure { println("Operation failed: ${it.message}") }
        .onSuccess { println("Operation succeeded.") }
}

1. val result = runCatching { ... }: We execute a block that may succeed (reading bytes) or fail (exception). The outcome is wrapped in a Result.

2. .also { ... }: The also function takes a lambda that operates on the Result, but importantly, it always runs—regardless of success or failure. It gets called with the Result receiver, so we can do any resource closing or final logging here.

When to Use also for Cleanup?

6.1 Keep Code Readable

runCatching can lead to very elegant chains, but it can also lead to extremely long call chains that become difficult to follow. If your chain has more than a few steps, consider splitting it into well-named functions or extension methods. That way, someone reading your code sees a logical pipeline rather than an endless chain of lambdas.

6.2 Avoid Abusing the Chaining

Sometimes, a straightforward try-catch is easier to read. If you only have one step that can fail and you want to log it, a simple try-catch might be the best option. runCatching shines when you want to store or pass around the result for further transformations.

6.3 Remember Resource Management

If you are dealing with resources that must be closed, such as file streams or database connections, rely on Kotlin’s built-in constructs like use. If you want unconditional cleanup, consider writing a small function that does the cleanup outside (or after) runCatching, or place it in an .also block. Don’t try to shoehorn the concept of finally directly into runCatching—embrace the Kotlin way of handling resources.

Final Words

Kotlin continues to evolve toward safer and more expressive idioms that reduce the risk of hidden errors. runCatching is a prime example of this evolution: it wraps a traditional concept (an operation that can throw) in a modern structure (Result<T>), allowing you to chain, transform, and handle errors in a unified flow.

Enjoyed the article?

SUBSCRIBE and CLAP on Medium

Ioannis Anifantakis anifantakis.eu | LinkedIn | YouTube | X.com | Medium