The power of IO in Haskell

08 Mar, 2023
Xebia Background Header Wave

IO is a hot topic these days in many FP communities. The IO here refers to the idea of wrapping and tagging your effectful actions within a special type, with the goal of offering better control over its execution. This contrasts sharply with the usual leeway languages give to the programmer, in which you can perform side effects anywhere, at any point. In Haskell, this mode of operation is the default; there is no way to escape IO if you need to perform side effects such as console, file, and network input/output, or random number generation, among others.

However, if one compares the documentation of System.IO from Haskell’s base library with more modern incarnations, such as Arrow Fx, Bow Effects, ZIO, and Cats Effect, Haskell seems quite underpowered in the IO arena. Whereas the latter libraries provide streams, resource management, and queues out of the box, those are nowhere to be found in Haskell’s library. This is just a wrong impression, but an easy one to get: the Haskell ecosystem provides all of those, but spread across many different libraries. In this post, we look at several of them, grouped by their goal.

Sounds interesting?

The idea of a separate IO has permeated functional programming communities. Several of our Academy courses discuss this topic, including Haskell Fundamentals, Functional Programming with Arrow.

In addition to our courses, we routinely offer talks and webinars on IO and related topics, many of them free! These talks and webinars focus on more concrete topics around Functional Programming and its applications, and span from 1 to 4 hours.

Table of contents


One fair question faced by many people the first time they see Haskell’s function to read the entire contents of a file, readFile :: FilePath -> IO String, is, “What happens when the file does not exist?” Well, an exception is thrown. This answer is not that different from other languages, but it may come as a surprise due to Haskell’s usual explicitness on error handling. However, in the case of IO, adding one additional layer of possible erroring to every function was deemed impractical. If it was the case, doing IO would turn into a deeply nested matching:

do config <- readFile "config"
case config of
Left e -> -- error case
Right config' -> do
let db = database config
conn <- openDbConnection db
case conn of
Left e -> -- error case
Right conn' -> do ...

Exception handling is implemented in the Control.Exception module. Its basic functions are to throw and catch exceptions:

throw :: Exception e => e -> a
catch :: Exception e => IO a -> (e -> IO a) -> IO a

On the surface, these functions are not that surprising: throw requires the exception to be thrown, catch requires the happy path and a function to recover if an exception is found. If you dig further, there’s something slightly odd in that an Exception type class is being used. This design allows catching different types of exceptions in the same code block:

do somethingWithFilesAndNumbers
<code>catch \(e :: IOException) -> -- handle IO problems
catch \(e :: ArithException) -> -- handle division by zero

The code above shows the usual pattern when dealing with exceptions in Haskell: declare the type of exceptions you want to catch by using a type signature (to be able to write those, you need to enable the ScopedTypeVariables extension).

Concurrent fibers

Fibers, as they are known in Arrow  (Kotlin), project Loom (Java), ZIO, or Cats Effect (both Scala), are IO actions that are run concurrently by the runtime (fibers are also known as green threads. Most operating systems support multithreading, but OS-level threads tend to be quite heavy, hence the need for something a bit more lightweight.

Fortunately, GHC (the main Haskell compiler) uses fibers / green threads by default in the Control.Concurrent module, unless one requests otherwise via forkOS. The aforementioned module is the basis for concurrency in GHC, but provides a very low-level interface. On top of it, we find the async package, an interface built upon the simple function async :: IO a -> IO (Async a).

This function receives an IO action, and spawns a new green thread to execute it. Not only that: the action as a whole can return a value, using the usual monadic interface, and we can query it later using the wait function, or cancel it altogether, stoping execution if it was not finished yet.

do compute_n <- async lengthyComputation
r <- checkDb
if r
then do n <- wait compute_n
updateDb n
else cancel compute_n

Arrow Fx, ZIO, and Cats Effect provide the same interface, but with different names. Scala’s Future is also quite close, although it does not provide the ability to interrupt the running computation.

Arrow Fx / ZIO / Cats EffectHaskell’s async
cancel / interruptcancel

Parallelization and races

One major reason for using lightweight green threads / fibers is to create many of them. Once created, you have two basic policies to gather their results: a) if you need every fiber’s result, you can wait for all of them to finish; or b) if you only need one fiber’s result to continue, you may just wait for the first one to finish, and then cancel all the others and dismiss their results. Both Arrow Fx  and ZIO  refer to those modes as parallelization and racing, respectively. In Haskell, we use different versions of wait:

waitBoth :: Async a -> Async b -> IO (a, b) -- parallel
waitEither :: Async a -> Async b -> IO (Either a b) -- race

Those functions are rarely used, though. The most common scenario is to create the different Asyncs and, right after, wait for them in one fashion or another. This pattern is embodied by the pair of functions:

concurrently :: IO a -> IO b -> IO (a, b)
race :: IO a -> IO b -> IO (Either a b)

Alas, those two functions are specific to two threads. What if you want to race between three threads? Or even more, you want to execute one process concurrently with a race of two others? The async library has a very ingenious solution: provide a data type to describe those scenarios, using the common Applicative and Alternative abstractions! The latter case we described is expressed as follows (thanks Harold for catching a bug in a previous version!):

runConcurrently $
(,) <$> Concurrently getInfoFromDb
<*> (Concurrently computeLocally <|> Concurrently downloadFromCache)


One problem we may face when working with fibers is the premature termination of one of them, usually because of an exception. Other threads providing information to it, though, do not cease to execute. This means that we might have useless work being done. This problem is sometimes referred to as leaking fibers.

Inspired by Erlang’s awesome support for process management, async introduces linking as a way to avoid this problem. You can either link another thread to the current thread, or two of them:

link :: Async a -> IO ()
link2 :: Async a -> Async b -> IO ()

The runtime ensures that, from now on, any exception thrown in one of them propagates to the other. Any unhandled exception in an Async block, in turn, makes that thread stop. So in the simplest case in which we do not handle any exception, linking two threads means that one is cancelled whenever the other is. No more remaining useless threads.

Communicating threads

Waiting for a thread to finish is a simple way to get information from it, but not the only one. GHC standard library contains a “one element box” called MVar as a primitive for communication. However, I strongly suggest refraining from using it. The documentation itself points us towards the right tool:

[…] however they are very simple and susceptible to race conditions, deadlocks, or uncaught exceptions. Do not use them if you need perform larger atomic operations such as reading from multiple variables: use STM instead.

STM stands for Software Transactional Memory. STM brings ideas from databases into usual variable handling. Instead of performing small simple operations over shared variables, you write a transaction script with one or more of those operations. The runtime then ensures that the entire script is executed atomically over a consistent world view. GHC was in fact one of the first compilers to provide runtime support for STM; others like Clojure make STM an integral part of their concurrency API. Cats STM provide similar functionality for the Scala ecosystem.

Take the following example in which we create a new transactional variable, or TVar, and concurrently increase its value.

do v <- newTVar 0
concurrently (add1 v) (add1 v)
add1 v = atomically $ do n <- readTVar
writeTVar (n+1)

The key part here is the function atomically, which executes the script given as argument. The script itself does not have type IO a, but STM a. By using a different type, the language ensures that only a restricted set of operations can be performed in this block. Reading and writing to transactional variables are two examples of what is allowed. That block also shows a very common pattern: create a transactional variable in a “parent” thread and then pass its reference to each “child” thread as an argument.

Had we not had the consistency guarantees, this code could end up with the wrong value for variable v: thread 1 reads value 0, thread 2 reads value 0, then both write value 1. STM prevents such a scenario, and we always obtain value 2 here, regardless of the order in which transactions are executed. How this is achieved is outside the scope of this article, but you should know that it relies on the ability to re-do computations. Haskell’s purity excels here; in other languages, users of STM must “promise” that they’ll never perform side effects here.

Variables are the simplest data structure that can be handled in a transactional fashion. Several libraries vastly extend this amount; here is a non exhaustive list of those.

TVarstm(Usual) variable
TMVarstmVariables which are either empty or full
TChan and TQueuestmFIFO queues
T{B,M}ChanT{B,M}Queuestm and stm-chansB = bounded, M = closeable
Mapstm-containersHash maps

One may ask, “Why have a specific transactional map when one could go with TVar (Map k v), that is, a variable that holds a map?” The reason is access to maps can be optimized in many cases, for example, if two scripts modify different keys. If we put everything in a TVar, this optimization is not possible, since the runtime tracks the map as a whole.

Going further from what we understand as communication, Haskell’s STM implementation contains other functionality closer to database. In particular, we can declare that our transaction has found inconsistent information, or in general cannot proceed, by calling retry. The runtime halts execution of that script, trying again once it detects some of the affected variables have changed.

Resource management

Once exceptions and threads enter the game, resource managements becomes hard. If you open a file or a network connection, you want them to be closed regardless of whether using it was successful or not. However, the naïve code below:

do r <- acquireResource
useResource r
releaseResouce r

does not guarantee that: if useResource throws an exception, then releaseResource is never called, for example.

The main pattern to handle resource correctly is called the bracket pattern, after the name of the function that implements it in HaskellArrow FxBow, or Cats Effect.

bracket :: IO a -- acquire
-> (a -> IO b) -- release
-> (a -> IO c) -- use
-> IO c

Most libraries for handling resources often provide a variant of bracket for those specific resources, usually prefixed by with. For example, withFile ensures that a file is correctly opened and closed; and withPool does so for a pool.

There are two main caveats for using bracket, though:

  1. Each time you use bracket, the rest of the code has to be indented one more level.
  2. Resources are always released in the reverse order in which they were acquired. In particular, this means that you cannot release a resource sooner if you are done with it; you have to wait until the end.

If you are only concerned with the first problem, the managed package provides a nicer API on top of bracket. In particular, what reads:

do bracket acquireResource1 releaseResource1 $ \r1 ->
bracket acquireResource2 releaseResource2 $ \r2 ->
use r1 r2

becomes the much nicer-looking, and devoid of nesting:

runManaged $ do
r1 <- managed (bracket acquireResource1 releaseResource1)
r2 <- managed (bracket acquireResource2 releaseResource2)
liftIO $ use r1 r2

The trick here is the use of a new Managed type that adds the managed construct. The only downside is that you need to wrap the code that uses the resources in a call to liftIO. As we shall see later, liftIO is used when we want to run IO actions in a monad which extends IO, but is not IOCats Effect and ZIO  manage resources in a very similar fashion.

The solution to the second problem comes with the resourcet package. The main function is allocate, whose main difference with the combination of managed and bracket is that it returns both the resource and a so-called release key:

allocate :: IO a -- acquire
-> (a -> IO ()) -- release
-> m (ReleaseKey, a)

If you do nothing more, then the behavior is exactly as bracket. But, with the release key, you may also call release to deallocate a resource before the block ends. Note that, from that moment on, using the resource turns into a runtime error, since the compiler does not track the liveness of the resource.

Pools of resources

You may be wondering how to handle not a single resource, but a pool of resources. A pool gives access to a given amount of resources, which can be asked or returned to the pool. The typical use case are database connections: you don’t want to have one single connection for a whole application, but you don’t want each single thread to create its own connection either. Following the steps of bracket, the pool package provides a function that handles a maximum number of resources:

createPool :: IO a -- acquire
-> (a -> IO ()) -- release
-> Int -- max amount
-> (Pool a -> IO b) -- use
-> IO b

From that moment on, you can ask for a resource using withPool. As you can see, bracket is the bread and butter of resource management in functional programming.

Scheduling and retry policies

A common feature across IO incarnations is an API for scheduling or retrying actions. The documentation for Arrow Fx, ZIO, and Bow discuss this, in fact, as a main feature.

And once again you might be asking: where’s the implementation for Haskell? Fear not, the retry package (which has been ported to Cats Effect) has your back! As in most implementations in other languages, the retry policy, that is, the way in which actions should be repeated if they fail, is separate from the actions themselves. The simplest policies are constant and exponential:

p1 = constantDelay 10000000 -- 1 second
p2 = exponentialBackoff 10000000

Furthermore, you can combine policies, and make your own easily: the retryPolicy function takes a history of the previous attempts, and returns whether the action should be attempted again, and in affirmative case after how many microseconds, by means of a Maybe Int value.

You can apply one such policy in two different ways, depending on whether you want to deal with exceptions or not. When using retrying, the question of whether to retry after one attempt is delegated to a function returning a Boolean.

retrying (constantDelay 1000000 <> limitRetries 3)
(\_ n -> return $ n >= 0) -- retry if the number is negative
(\_ -> computeNumber)

In this case, we have obviated the first argument to the retry check and the attempt; that represents the history of retries before the current one. In that way, the decision can be better informed.

The counterpart for exception-raising functions is recovering. In this case, the function itself takes care of catching the exceptions in which you are interested, as you would go with catch. But those handlers also need to decide whether the action should be retried. Let us write a version of the function above that also retries if an ArithException is raised:

recovering (constantDelay 1000000 <> limitRetries 3)
[\_ -> Handler $ \(e :: ArithException) -> return True]
(\_ -> computeNumber)

If you are just interested in retrying regardless of the exception, the library provides a recoverAll.

Data streaming

Manually handling of reading or writing information at the right moment, while consuming a minimal amount of resources, is a challenge in itself. For awhile now, attention has shifted to (reactive) streams as a solution for handling incoming data in a functional fashion. These solutions are not only circunscribed to functional languages, ReactiveX or Reactor target several languages with a similar API for all of them. Arrow Fx, in fact, has decided to wrap those libraries instead of providing their own, a path followed by ZIO. FS2 is another implementation of functional streams in the Scala world, built on top of Cats Effect.

The Haskell community has produced a diversity of libraries in this area, although at this point, conduit and pipes seem to be the most used. At the surface level, they are quite similar; I tend to use conduit because at this moment there are more integrations with other libraries in the ecosystem.

In the conduit library, the basic building blocks are called, unsurprisingly, conduits. More precisely, a ConduitM i o m r represents a stream that consumes values of type i and produces values of type o, using effects coming from m. Conduits may also return a final value r when the stream has finished; think for example of producing statistics when computation has finished. Let’s see some examples from the Combinators module:

-- reads ByteString from a file
sourceFile :: FilePath -> ConduitT i ByteString IO ()
-- saves the incoming data in a file
sinkFile :: FilePath -> ConduitT ByteString o IO ()

In addition to that module, the conduit-extra library defines sources and sinks for other sorts of resources.

Many of the transformations share the same names as their list counterparts. For example, map transforms incoming values and sends them as output, as hinted by the type:

map :: Monad m => (a -> b) -> ConduitT a b m ()

You can connect several conduits into a larger pipeline by using the (.|) operator. Of course, input and outputs have to match for the compiler to accept it. For example, imagine that we had a toUppercase operation that turns the letters ByteString into its uppercase counterpart. Then the following block:

runConduitRes $ sourceFile "input" .| map toUppercase .| sinkFile "output"

opens the input file and writes an uppercase version in output. Note the use of runConduitRes to execute the pipeline, until then the conduit is just a description of what ought to be done.

As mentioned above, the power of these libraries comes from the wide range of integrations readily available. In this case, you can open a file, transform it into JSONput the value into a transactional queue, which is then read and sent to a RabbitMQ queue or a Kafka topic.

MonadIO and friends

The section about Managed has already hinted about one important pattern for IO actions in the Haskell community: monads, which are not IO, but extend their abilities instead. Sometimes you also need to use transformers to add those new capabilities, such as logging. But of course, you want those operations that can be performed regardless of these extensions to be used as far as possible without changes. The trick is to use MonadIO instead of the concrete IO, as hinted by the following function in conduit:

print :: (Show a, MonadIO m) => ConduitT a o m ()

MonadIO defines a single operation called liftIO, which executed an IO action in the realm of the more capable monad. Since ending up with a monadic stack in which IO is just a component is not that strange in big applications, the community has produced versions of many of the libraries discussed in this article generalized (“lifted”) to MonadIO, like lifted-baselifted-async, and stm-lifted.

Note that the story around generalizing IO operations gets complicated pretty fast. Lifting something like printing to console is simple, but when errors and exceptions are involved, we need to use something more powerful than MonadIO. The documentation for unliftio, one of the simplest solutions, describes the problem and the design space quite well. This problem is in any case not so important to users of IO actions, which can just use the lifted operations without having to know the technicalities around how they are lifted.

A fair question is: why is this not a problem in other libraries? Learning from Haskell’s mistakes, more modern libraries define most of the operations mentioned in this article using type classes from the start (check the documentation for Cats Effect and Arrow Fx). ZIO  goes even further, using a completely different method of combining different effects.


The Haskell community has produced many interesting libraries for working with IO actions. In many cases, though, the main features are scattered in different libraries, and sometimes it’s not clear where to look or how to choose between competing approaches. In contrast, more modern communities have produced all-in-one solutions in this space. My hope is that this article brings this fact to light, and shows that Haskell is well equiped for dealing with IO.

And don’t forget to check our Xebia Functional Academy page for information about upcoming talks around Functional Programming and its applications, and courses to dive into IO and many other facets of functional programming.


Get in touch with us to learn more about the subject and related solutions

Explore related posts