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

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
try
block: You place the code that you suspect might throw an exception insidetry
.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.finally
block: This block is optional. Code insidefinally
runs no matter what happens in thetry
orcatch
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:
onSuccess {...}
is called if the operation succeeded.onFailure {...}
is called if an exception happened.
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
- They both capture exceptions: Whether you use
try-catch
orrunCatching
, you are still making sure that if something goes wrong, you handle it in some manner. - They do not eliminate the possibility of exceptions: Even though
runCatching
returns aResult
, you still need to manage the fact that some part of your code might fail. - They both can handle cleanup: You can replicate some of the logic you might place in a
finally
block by chaining calls on theResult
object (like usingonFailure
to free resources). Alternatively, you can place cleanup inside therunCatching
block if needed. It just requires a slightly different organization.
1. Return type:
try-catch
by itself doesn’t produce an explicit type that you can store and pass around. You can manually return different values if you like (like returningnull
on failure or the real value on success), but you have to do that yourself.runCatching
always yields aResult<T>
, which means you get a standard wrapper that you can pass around, chain calls on, or transform with extension functions likemap
,recover
, and so forth.
- With
try-catch
, your code might become more nested or cluttered if you have multiple potential points of failure. You end up writing multipletry-catch
blocks or piling up logic in a single block. - With
runCatching
, you often see a more linear flow, because after you wrap the operation, you can chain methods on theResult
. It can be more declarative, showing clearly how to handle success and failure, and how to transform results or recover from errors.
try-catch
usually means “stop the normal flow, go to the catch block, and then move on.”runCatching
, on the other hand, integrates neatly with transformations. You can do:
With this approach you chain operations in a pipeline rather than controlling flow with blocks.
4. Verbosity:
try-catch
is fairly easy to read if you have one or two lines of code that might throw an exception. However, if you do a lot of transformations or pass the result around, you might end up with either multiple nestedtry-catch
blocks or a single block with complex logic.runCatching
can help reduce boilerplate in some scenarios, because you no longer need to wrap every step in atry
block. Instead, you wrap the entire operation once and handle everything as aResult
.
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
isSuccess
/isFailure
: Boolean checks to see if the result is success or failure.getOrNull()
: Returns the value if it’s a success, ornull
if it’s a failure.exceptionOrNull()
: Returns the exception if it’s a failure, ornull
if it’s a success.onSuccess(action: (T) -> Unit): Result<T>
: Runs the given lambda if this result is a success.onFailure(action: (Throwable) -> Unit): Result<T>
: Runs the given lambda if this result is a failure.map(transform: (value: T) -> R): Result<R>
: Transforms the value if success, propagates the failure if failure.recover(transform: (Throwable) -> T): Result<T>
: If it’s a failure, uses the given lambda to provide a fallback, effectively recovering from the error.
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) }
- We open the
FileInputStream
first. - We handle any exception that may occur during the read in
.onFailure
. - Inside that block, we close the stream.
- If everything succeeds, the stream remains open after
runCatching
(you’d typically handle it elsewhere or in a separate approach).
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}") } }
use
ensures thatstream.close()
is always called after the block (including if an exception is thrown).- This removes the need for a manual
finally
block or manual close calls.
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.") } }
- The stream is opened before
runCatching
. - We run the risky operation inside
runCatching
. - Then we unconditionally close the stream in a regular
try/catch
. - Finally, we check the
Result
to handle success/failure.
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.
- This is analogous to a
finally
block in a traditionaltry-catch-finally
chain. - Unlike
onFailure
oronSuccess
, which only run in one scenario,also
is unconditional.
When to Use also
for Cleanup?
- Unconditional final actions: If you have a resource that must always be cleaned up regardless of errors — and you’d rather not embed a
try-finally
insiderunCatching
—usingalso { ... }
is a neat way to centralize that logic right after the result is produced. - Logging: You might use
also { ... }
to log “Operation finished” or do some metric tracking, because it’s guaranteed to run in both success and failure paths.
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.
- If you’ve ever found your code cluttered with repeated
try-catch
blocks, or returningnull
or special flags to indicate errors,runCatching
provides a more elegant way. - If you’re new to Kotlin, consider practicing with small examples of
runCatching
to see how it compares totry-catch
. - If you’re leading a Kotlin team, decide on a coding standard so everyone knows when to use
runCatching
vs. a traditionaltry-catch
. Consistency across the codebase will be key to readability.
Enjoyed the article?
SUBSCRIBE and CLAP on Medium
Ioannis Anifantakis anifantakis.eu | LinkedIn | YouTube | X.com | Medium