Post #elixir

The Problem with Piping

Elixir's beloved pipe operator gets misused.

Jun 24, 2026 13 minute read

Every Elixir developer goes through the same phase…

You discover the pipe operator and suddenly it’s everywhere. Every function call, every data transformation, every error case gets threaded through a beautiful flowing chain of |>.

It looks clean. It feels elegant. And it’s slowly making your code worse.

I know because I lived it. I wrote pipes that were seven, eight, ten steps deep. I piped anonymous functions into transactions. I piped tap into then into case. I built entire features as single pipe chains and felt clever about it.

I was not being clever. I was being a problem.

Pipes are not the villain here.

They’re a tool, and a genuinely good one when used for the thing they were designed to do. The problem is that we’ve turned them into a religion.

Some people treat pipe density as a code quality metric. “How idiomatic is this?”. We contort our logic to fit a pipe instead of reaching for the right construct.

This post is about when and how to stop, what to do instead.

Pipes, As Intended

Let me be clear: pipes are good.

They’re one of the best things about Elixir and I’m not here to tell you to stop using them entirely.

The pipe operator was designed for a specific thing: pure data transformations. Take some input, apply a series of functions that each take data in and return data out, get a result.

No side effects. No error branching. Just clean, linear flow.

1..10
|> Enum.map(&(&1 * 2))
|> Enum.filter(&Integer.is_even/1)
|> Enum.sum()
# => 110

This reads top to bottom, left to right. Each step does one thing. You can see the whole shape of the computation without jumping around.

Compare it to the nested version:

Enum.sum(Enum.filter(Enum.map(1..10, &(&1 * 2)), &Integer.is_even/1))

The nested version makes you read inside-out. It’s technically the same computation but it’s genuinely harder to parse.

The pipe wins here, no question.

This is what pipes were built for. Simple, pure, stateless data flow. If every pipe chain in your codebase looked like this, I wouldn’t be writing this post.

But they don’t. And that’s where it gets gross.

When Pipes Go Wrong

Here is something I have seen in production. I have written versions of this myself. You probably have too.

fn ->
case Repo.update(changeset) do
{:ok, thing} ->
sync_stuff(thing)
broadcast_thing_updated(thing)
thing
{:error, reason} ->
Repo.rollback(reason)
end
end
|> Repo.transaction()
|> tap(fn
{:ok, {thing, {order, true}}} ->
broadcast_order_created(order)
{:ok, {thing, {order, false}}} ->
broadcast_order_updated(order)
{:ok, {thing, _}} ->
broadcast_thing_updated(thing)
_ ->
:ok
end)
|> case do
{:ok, {thing, _}} ->
{:ok, thing}
result ->
result
end

I kid you not, this exact pattern, a raw closure into Repo.transaction, tap for broadcasts, and a case at the end to peel the tuple open, is something I’ve found in least six different places in a codebase I work on.

This isn’t one person’s weird “style” thing. This is code that’s been copied and pasted, extended and evolved over time.

To be fair, some of this (like the bare fn/2) probably got auto-formatted by something like Styler.

But that’s not the point!! It’s not nice!!

Lemme walk you through what I personally think is wrong with the example~!

Bare Functions, Gross…

An anonymous function. Not a named function, not a private helper.

The pipeline just starts with a raw fn -> end block floating in the middle of a larger function body. Database calls inside it. Side effects. Error handling.

A pipeline implies flow: Input in, output out, each step transforms the value and passes it forward.

I feel like my issue with the example is that a bare fn as the pipe head breaks that contract before the chain even starts.

There’s no data flowing in. There’s just a block of code that happens to be passed as an argument.

I don’t have a technical reason for it… But this should already feel wrong.

It’s weird, right? Because it works. Elixir lets you pipe anything into anything as long as the types line up.

A function is a value. You can pipe it into Repo.transaction/1. The compiler won’t stop you. The linter won’t complain.

The only thing that flags it is that it’s gross.

Transactions?

So the bare fn is passed into a Repo.transaction/2. Wrap the closure, run the transaction, and get a result. Fine. OK.

But Repo.transaction can succeed or fail. That function returns either:

  • {:ok, result}, or…
  • {:error, reason}.

That’s the contract: it’s an operation that can go either way, and the caller is supposed to check.

The pipe doesn’t check. Not in itself, at least.

It just takes the tuple, whatever shape it is, and feeds it into the next step. The pipe doesn’t discriminate. It’s just a tube.

A pipeline with branching error states isn’t a pipeline anymore. It’s a tube full of surprises~!!

I’d suggest not trying to use transactions this way in a pipeline at all.

In a normal function, you’d write case Repo.transaction(...) do and handle branches explicitly. The branching is visible. The error path has its own block. You can see where things go wrong and what happens when they do.

In the piped version, the error tuple just falls through…

It hits tap. It hits case do. Every step downstream has to figure out the shape of the data again, because nobody handled the error where it happened.

The error isn’t being managed. It’s being propagated into places it was never meant to reach.

The pipe makes it look like everything is one smooth chain, and that’s disingenuous.

Don’t Tap This :)

tap/2 is being used for side effects here. Broadcasting events. But tap is for inspecting or logging… not for conditional business logic.

If you squint, I can kind of see why someone reached for it. The pipe gives you a value. tap lets you do something with that value without changing it. It’s the only pipe-friendly way to inject a side effect.

But tap doesn’t know what it’s looking at.

The tap here has four clauses matching on the exact internal shape of the transaction result:

  • {:ok, {thing, {order, true}}} → broadcast this
  • {:ok, {thing, {order, false}}} → broadcast that
  • {:ok, {thing, _}} → broadcast the other thing
  • _ → do nothing

If the shape changes anywhere upstream… if someone renames a field, or nests the tuple differently, or returns a new kind of success… this tap silently stops matching. Falls through to _ -> :ok.

No compiler warning. No test failure (you did… write tests for the side effects, right?).

The broadcasts just stop. Quietly. Nobody notices until a customer complains they didn’t get notified.

I don’t know about you, but I’d rather my side effects be loudly wrong than silently absent.

tap has a job. Inspecting values mid-pipe. Logging. Debug prints. That’s what it’s for. The moment you’re using it to branch on business logic, you’ve got the wrong tool.

Destructuring, Four Times

Finally, case do at the very end. Unwrap the {:ok, thing} and hand it to the caller.

But we’ve already pattern-matched this tuple:

  1. Inside the fn body.
  2. Inside Repo.transaction.
  3. Inside tap.
  4. This is the fourth time we’re destructuring the same data shape.

Four times. In one pipe.

At this point the pipeline isn’t clarifying anything. It’s just making you read the same structural assumption over and over and hope it holds across every step.

Here’s what this code actually communicates: “I wanted this to look like a pipeline, so I forced it into a pipeline.”

The transaction logic, the error handling, the broadcasts, and the return value shaping are four different concerns. Flattening them into a single linear chain doesn’t unify them. It just buries the distinctions.

Refactor It, Bro

So the pipe is bad. What do we do instead?

Extract a Function?

Your first instinct might be to pull the broadcast logic into a private function.

Extract the pattern matching, keep the pipe, just clean it up a little.

def approve_thing(changeset) do
Repo.transaction(fn ->
case Repo.update(changeset) do
{:ok, thing} ->
sync_stuff(thing)
broadcast_thing_updated(thing)
thing
{:error, reason} ->
Repo.rollback(reason)
end
end)
|> handle_transaction_result()
end
defp handle_transaction_result({:ok, {thing, {order, true}}}) do
broadcast_order_created(order)
end
defp handle_transaction_result({:ok, {thing, {order, false}}}) do
broadcast_order_updated(order)
end
defp handle_transaction_result({:ok, {thing, _}}) do
broadcast_thing_updated(thing)
end
defp handle_transaction_result({:ok, {thing, _}}) do
{:ok, thing}
end
defp handle_transaction_result(result) do
resul
end

Wait. No. That’s also bad. Better, but still bad.

I took one problem and hid it somewhere else: Now I have a private function whose entire existence is defined by one pipeline’s return shape.

Every clause is hard-coupled to the transaction result tuple. This function can’t be reused. It can’t be tested in isolation.

To be honest, I think this is worse than the original.

Now you have to jump around the module to understand what’s happening. It’s split into multiple clauses.

You’ve just distributed your pipeline into multiple places.

The Actual Problem

Here’s the thing. The example shouldn’t be a pipeline at all.

Look at what it does:

  1. It runs a transaction.
  2. Then it broadcasts one of four possible events depending on the result shape.
  3. Then it returns either {:ok, thing} or an error.

That’s not a pipeline. That’s control flow.

We keep trying to make it a pipe because “pipes are idiomatic”. Because Elixir code “should” pipe.

But the language also gave us case. And with. And cond. And if. Those aren’t second-class constructs.

They’re not what you use when you can’t use a pipe. They’re what you use when the problem is branching, not flowing.

The question isn’t “how do I pipe this?” The question is “is this a pipe?”

Tools for the Job

Elixir gives you a few ways to branch. Here’s when I reach for each.

The Case Statement

Use a simple case when you:

  • Have a value
  • You need to branch on its shape.

Each branch can do anything: side effects, further control flow, whatever. The case statement is the most basic thing, you’ll probably use it the most.

case Repo.update(changeset) do
{:ok, thing} ->
sync_stuff(thing)
broadcast_thing_updated(thing)
{:ok, thing}
{:error, reason} ->
{:error, reason}
end

Simple.

The With Statement

When you have a sequence of steps that depend on subsequent steps succeeding, you can use with.

It can be a little confusing at first, but it’s actually very elegant once you get the hang of it. You can either omit the else clause and it’ll bubble up the first error to the caller, or you can handle the errors in a custom way!

with {:ok, thing} <- Repo.update(changeset),
{:ok, order} <- sync_stuff(thing) do
broadcast_order_created(order)
{:ok, thing}
end

The If Statement

The if statement is for a single binary choice. One condition, two branches. It’s the simplest thing and sometimes that’s exactly what the code needs.

Don’t overthink it. Don’t undersell it. You don’t have to pattern match, or split out private functions, or do anything fancy.

Sometimes a simple if is the right tool for the job. It returns nil if the condition is falsey and you omitted the else clause.

if order_created do
broadcast_order_created(order)
end

The Cond Statement

And finally, if you have multiple independent conditions to check, use cond.

Basically, it’s a fancy way of doing a series of if and else if statements. It reads like a series of questions and answers.

cond do
order_created?(order) ->
broadcast_order_created(order)
order_updated?(order) ->
broadcast_order_updated(order)
true ->
broadcast_thing_updated(thing)
end

None of these basic control flow operators are un-idiomatic. None of them make your code “less Elixir.” They’re in the language for a reason.

What “Good” Looks Like

Let’s stop trying to make this fit in one function.

The original pipe was trying to do three things at once: run a transaction with error handling, decide which broadcast to fire, and return the result.

One flattenend chain for three different concerns.

What if each concern got its own function, and each function used the right construct for its job?

Start with the transaction. It’s a database operation with fallible steps: update the record, sync stuff, return the thing. That’s with.

defp do_approve_thing(changeset) do
Repo.transact(fn ->
with {:ok, thing} <- Repo.update(changeset),
{:ok, thing} <- sync_stuff(thing) do
thing
end
end)
end

with chains the steps. If Repo.update fails, it short-circuits. If sync_stuff fails, same. The transaction wraps it. One job, one function.

I’m also using Repo.transact/2 here, which was pioneered by Sasa Juric.

It extends the semantics of Repo.transaction/2 to automatically roll back on any {:error, reason} return value. It’s a nice way to avoid boilerplate.

Now the broadcast. We have a thing and we need to figure out which event to fire. That’s cond.

defp broadcast_thing!(thing) do
cond do
thing_created?(thing) ->
broadcast_order_created(thing)
thing_updated?(thing) ->
broadcast_order_updated(thing)
true ->
:noop
end
end

cond checks independent conditions. Created? Broadcast. Updated? Broadcast. Neither? Move on. The ! convention tells you this function does side effects. One job, one function.

Now the public function. It orchestrates. Run the transaction, broadcast the result, return.

def approve_thing(changeset) do
with {:ok, thing} <- do_approve_thing(changeset) do
:ok = broadcast_thing!(thing)
{:ok, thing}
end
end

with chains the fallible step. If the transaction fails, the error falls through. If it succeeds, we broadcast and return {:ok, thing}.

Three functions. Three concerns. Each one uses the construct that fits its shape.

No pipe. No tap. No anonymous function. No pattern-matching the same tuple four times.

The goal was never fewer lines. The goal was less mental overhead. These functions tell you what they do by existing.

They’re also reusable in that they’re not coupled to a single pipeline’s return shape.

You can test them in isolation. You can read them in isolation. You can reason about them in isolation.

Compare this to the original pipeline. Four stages flattened into one chain. The new version has more functions and they are unquestionably better code.

The Problem With Piping

Here’s the hueristic I use now. It’s one question.

Is this a pipe, or is this control flow?

A pipe is a sequence of pure transformations. Data in, data out, no branching. Every step is a named function with no side effects and a predictable return type. This is what pipes were built for and they’re genuinely beautiful at it.

Control flow is everything else.

Branching on {:ok, result} vs {:error, reason}. Broadcasting events based on data shape. Rolling back transactions on failure. Making decisions.

That’s case and with and cond. That’s “assign the result to a variable and figure out what to do next.”

The problem isn’t that Elixir lets you pipe these things. The problem is that we’ve convinced ourselves that pipes are the default and everything else is a compromise.

Pipes are a reading aid. The moment they stop helping you read the code, they’re hurting you.

Don’t reach for then/2, tap/2, or whatever, just to make a pipe work.

Just unpipe it.

Referenced by

1
  1. 01.
    The Problem with Pattern Matching

    I don't like pattern matching.

    #elixir

Directory

1
  1. 01.
    Posts

    No description yet. Typical mysterious little page.

    (no tags yet)