Why Concurrency Feels Different in Haskell
We tackle Haskell’s approach to concurrency, a critical and fundamental concept in modern software development
Why Concurrency Feels Different in Haskell

By Antonio Hernández-Garduño
This article begins a series of posts about some advantages that Haskell — and, more broadly, functional programming — brings to the software development ecosystem. Here we tackle the important issue of concurrency, and in particular how Haskell’s approach to software transactional memory gives us a level of composability that traditional locking mechanisms often struggle to provide.
Concurrency in modern software
Modern software is rarely a simple sequence of isolated computations. Web servers handle many requests at once; desktop applications react to user input while performing background work; distributed systems coordinate with databases, queues, caches, and external services; and even a single process may have several internal threads trying to access shared state.
Concurrency problems can therefore arise at different levels. Some are external: coordinating with a database, communicating with another service, or dealing with network failures. Others are internal: coordinating several threads inside one running program as they access shared memory. Software Transactional Memory, or STM, is mainly concerned with this second kind of problem: how to make shared in-memory state safer and easier to reason about.
The standard way to deal with shared memory concurrency is through locking mechanisms. Once a thread takes control of a memory resource, other threads are locked out until the resource is released. This approach is powerful and widely used, but it has an important drawback: locks tend to leak implementation details. The parts of the program that access a shared resource often need to know which lock protects it, in what order locks must be acquired, and how long they may be held.
As a result, locking code can become difficult to compose. Two functions may each be correct in isolation, but combining them can introduce deadlocks, race conditions, or performance bottlenecks. This is one of the places where Haskell offers a different perspective.
Haskell’s STM proposition
Software Transactional Memory is an alternative approach to shared-memory concurrency. The central idea is to group a collection of memory operations into a single atomic transaction. “Atomic” here means indivisible: from the point of view of other threads, the transaction either happens completely or does not happen at all.

A simplified way to think about STM is this:
- memory accesses inside a transaction are recorded;
- the runtime proceeds optimistically, as if the transaction will be compatible with other concurrent transactions;
- before committing the transaction, the runtime checks whether the values read by the transaction are still valid;
- if they are valid, the transaction commits;
- if not, the transaction is retried, and its tentative effects are discarded.
This gives the programmer a much higher-level abstraction. Instead of saying, “Acquire this lock, then that lock, then carefully release them in the right order”, the programmer says, “These memory operations belong together; please run them atomically”.
Haskell’s type system and effect separation make this especially attractive. STM computations live in their own monad, separate from general IO. This prevents arbitrary irreversible effects — such as printing to the console, writing to a file, or launching missiles — from being performed inside a transaction that may later need to be rolled back.
Monadic composability
One of the key ingredients that makes Haskell’s STM elegant is the Monad abstraction. A monad provides a way to sequence computations while preserving some surrounding context: possible failure, state, logging, input/output effects, transactions, and so on.
To make this concrete, consider the familiar IO monad. An IO a value represents a computation that may interact with the outside world and eventually produce a value of type a. For example¹:
putStrLn :: String -> IO ()
getLine :: IO String
The crucial operation for sequencing monadic computations is the bind operator:
(>>=) :: IO a -> (a -> IO b) -> IO b
Roughly, this says: if we have an IO computation that produces a value of type a, and a function that uses such a value to produce a new IO computation of type b, then we can combine them into a single IO b computation.
A second important operation is return:
return :: a -> IO a
which puts a plain value into the IO context. (In modern Haskell, pure is often preferred, with the same idea of putting a value inside a monadic context.)
For example, suppose we have a function that performs a computation and logs its progress:
fooLog :: Int -> IO Float
And another one:
gooLog :: Float -> IO Double
We can compose them as follows²:
wholeProcess :: Int -> IO Double
wholeProcess n =
return n >>= fooLog >>= gooLog
This means: start with the integer n, place it in the IO context, apply fooLog, then apply gooLog, preserving the IO context (for logging) throughout.
The important point is that the functions are composable because they share the same monadic structure.
Now consider an irreversible operation:
launchMissiles :: Target -> IO ()
Once this function is called, the external world has changed. We cannot simply roll it back. This is exactly the kind of operation that should not be allowed inside an STM transaction, because STM transactions may be retried or discarded.
This is where Haskell’s type discipline becomes valuable. STM computations have type STM a, not IO a.
That distinction matters. Inside STM, we can read and write transactional variables. But we cannot perform arbitrary IO. The type system separates reversible transactional memory effects from irreversible real-world effects.
The STM monad
At a high level, Haskell’s STM library introduces the STM monad:
data STM a
instance Monad STM
The type STM a represents a transactional computation that, if committed, produces a value of type a.
The central operation that runs an STM transaction is:
atomically :: STM a -> IO a
This is the “exit door” from the protected STM environment into the general IO environment. An STM computation is built up transactionally, and then atomically runs it as a single atomic block.
Two other essential STM operations are:
retry :: STM a
orElse :: STM a -> STM a -> STM a
The meaning of retry is subtle and powerful. It says: “This transaction cannot proceed right now.” When a transaction calls retry, its tentative effects are discarded, and the transaction blocks until one of the transactional variables it has read changes.
The orElse operator provides alternatives. In:
transaction1 `orElse` transaction2
STM first tries transaction1. If it succeeds, its result is used. But if transaction1 calls retry, its tentative effects are discarded and transaction2 is tried instead.
To access transactional memory, STM provides transactional variables:
data TVar a
newTVar :: a -> STM (TVar a)
readTVar :: TVar a -> STM a
writeTVar :: TVar a -> a -> STM ()
A TVar a is a transactional variable containing a value of type a.
- newTVar creates a new transactional variable.
- readTVar reads its current value inside an STM transaction.
- writeTVar writes a new value inside an STM transaction.
A blocking transaction with retry
Let us model a bank account as a transactional variable containing an integer balance:
type Account = TVar Int
Here is a withdrawal operation (note the use of the >>= bind operator):
withdraw :: Account -> Int -> STM ()
withdraw account amount =
readTVar account >>= \balance ->
if balance >= amount
then writeTVar account (balance - amount)
else retry
This says: read the balance. If there is enough money, subtract the requested amount. Otherwise, call retry.
(The notation \balance -> … is a lambda, or anonymous function. The backslash begins the function, balance is its argument, and the expression after -> is its body.)
The remarkable part is that this does not fail immediately, nor does it require us to manually block a thread with locks or condition variables. Instead, STM knows that the transaction depends on account, and it will retry the transaction when that TVar changes.
So we can write:
atomically (withdraw checking 100)
If checking has at least 100 units, the transaction commits. If not, the transaction waits until the balance changes.
Alternatives with orElse
Now suppose we have two accounts, checking and savings, and we want to withdraw from checking if possible, but fall back to savings otherwise.
We can write:
withdrawFromEither :: Account -> Account -> Int -> STM Account
withdrawFromEither checking savings amount =
(withdraw checking amount >> return checking)
`orElse`
(withdraw savings amount >> return savings)
This transaction first tries to withdraw from checking. If that succeeds, it returns checking. But if the withdrawal from checking calls retry, STM discards that attempt and tries the withdrawal from savings.
(Here >> is a sequencing operator similar to >>=, but used when the result of the left-hand computation is not needed.)
If both accounts lack sufficient funds, then the whole transaction blocks until one of the relevant balances changes.
This is a beautiful example of composability. The withdraw function does not need to know whether it is being used by itself, as part of an alternative, or as part of a larger transaction. It simply describes one transactional operation.
Sequential composition inside atomically
Let us add a deposit operation:
deposit :: Account -> Int -> STM ()
deposit account amount =
readTVar account >>= \balance ->
writeTVar account (balance + amount)
Now suppose we want to pay rent. We want to withdraw from either checking or savings, and then deposit the amount into the landlord’s account:
payRent :: Account -> Account -> Account -> Int -> STM Account
payRent checking savings landlord amount =
withdrawFromEither checking savings amount >>= \source ->
deposit landlord amount >>
return source
We can run the whole operation atomically:
atomically (payRent checking savings landlord 700)
The entire operation is now one transaction:
- Try withdrawing from checking.
- If that cannot proceed, try savings.
- If one withdrawal succeeds, deposit the amount into landlord.
- Commit the whole transaction atomically.
No partial state is visible. We cannot end up in a situation where the money was withdrawn but not deposited. We also did not have to expose lock ordering, lock acquisition, or condition-variable signaling to the programmer.
This is the central promise of STM in Haskell: smaller transactional programs can be assembled into larger transactional programs without losing their correctness story.
Conclusion
Concurrency is difficult because it forces us to reason about many possible interleavings of events. Traditional locks are useful, but they often make abstraction harder: to safely combine two locking routines, we may need to know their internal locking discipline.
Haskell’s STM offers a different model. It lets us describe shared-memory operations as composable transactions. The STM monad gives us a protected environment for reversible memory effects; atomically commits a complete transaction; retry gives us composable blocking; and orElse gives us composable alternatives.
That is why concurrency feels different in Haskell. The language does not eliminate the conceptual difficulty of concurrent programming, but it gives us abstractions that let us build larger concurrent systems from smaller, safer pieces.
For the classic presentation of STM in Haskell, see Harris, Marlow, Peyton Jones, and Herlihy, *Composable Memory Transactions*.
About the author: Antonio Hernández-Garduño is an experienced Haskell developer whose work spans distributed systems, blockchain infrastructure, and zero-knowledge protocols. He is a senior software developer at LambdaCrafters.
Notes
¹In Haskell, double colon starts a type declaration. For example,
f :: a -> b -> c
means “f takes two arguments of types a and b, respectively, and returns a result of type c”. So putStrLn takes only one argument of type String, while getLine doesn’t take any argument but provides a result of type IO String, that is to say, a string within the IO context.
²Note that >>= is an infix operator, meaning it is written as u >>= f. So, if u :: IO a and f :: a -> IO b then the resulting u >>= f is of type IO b. Also note, in Haskell we don’t use parenthesis around function arguments, so we write f x instead of f(x), and f x y instead of f(x, y).
메타데이터
- post_id
- 3f14d74b8db4
- slug
- why-concurrency-feels-different-in-haskell-3f14d74b8db4
- url
- https://medium.com/@lambda-crafters/why-concurrency-feels-different-in-haskell-3f14d74b8db4
- canonical_url
- https://medium.com/@lambda-crafters/why-concurrency-feels-different-in-haskell-3f14d74b8db4
- author_url
- https://medium.com/@lambda-crafters
- status
- ok
- fetched_at
- 2026-06-09 15:37:30