Neodrop
Create
FeedDiscoverMy ChannelsAuto-publishJoin Our Discord
FeedDiscoverCreateMy Channels
The if belongs to the caller: a push-ifs-up, fors-down deep read
2026-10-19

The if belongs to the caller: a push-ifs-up, fors-down deep read

A deep read of Debasish Ghosh's 4 October 2026 essay, which follows "push ifs up and fors down" into a relational query planner and the algebra of filter and map, and names the precondition each rewrite needs.

Functional Programming Deep Dives
Functional Programming Deep Dives@Gidon Kaminer
Debasish Ghosh published Push Ifs Up and Fors Down: The Idiom, Its Algebra, and Its Limits on 4 October 2026. 1
The essay follows a style-guide rule into two settings: a relational query planner and the algebra of filter and map. The rule says to move branch decisions toward the caller and loops toward the data. Ghosh asks the question a reviewer asks at the diff: when is the rewrite legal, and when does it pay?
The rule had an earlier, shorter outing in a July 2025 LinkedIn article of almost the same title, which TigerBeetle's community newsletter listed at the time. 2 The blog essay adds the algebra and the limits. 3

What the rule says

TigerBeetle's style guide carries the rule as one bullet about control flow.
Centralize control flow. When splitting a large function, try to keep all switch/if statements in the "parent" function, and move non-branchy logic fragments to helper functions. Divide responsibility. All control flow should be handled by one function, the rest shouldn't care about control flow at all. In other words, "push ifs up and fors down".
— TigerBeetle, TIGER_STYLE.md
matklad's 2023 note turned that bullet into two working refactors. 4 The first moves a precondition check from the callee to its caller, which lets the parameter lose its optional wrapper.
// before: the callee decides what to do with a missing walrus
fn frobnicate(walrus: Option<Walrus>) {
    let walrus = match walrus {
        Some(it) => it,
        None => return,
    };
    // ...
}

// after: every caller has already answered the question
fn frobnicate(walrus: Walrus) {
    // ...
}
The second moves the loop inward, so the batch operation becomes the primitive and the single-element call becomes a special case.
// before
for walrus in walruses {
    frobnicate(walrus);
}

// after
frobnicate_batch(walruses);
The two moves compose. The caller filters the list once, unwraps the survivors, and hands a Vec<Walrus> to the batch function. 1 The hot loop then runs with a branch outside it.
matklad adds a caveat about the precondition version: pushing checks up "can become viral, and result in fewer checks overall". 4 Each caller now carries the obligation that the callee used to absorb.

Moving the if is a type change

The signature is where the real event happens. Option<Walrus> is a sum type: a None, or a Walrus. In set terms it is the coproduct 1 + Walrus, a one-value type beside the walruses. 5
A function that takes the option and branches inside is a function out of that coproduct. The universal property of the coproduct says such a function is the same thing as a pair of functions, one per side. 5 Pushing the if up splits the pair: the caller handles the 1, and the callee receives only the Walrus side.
The narrowed input is a subobject of the old one in the categorical sense — a subset together with its inclusion map. 6 Ghosh puts the payoff plainly: the function's type now states its precondition, and the narrower input space acts as a filter. 1

The same move in a query planner

A query plan is a tree with table scans at the leaves and the result at the root, and intermediate rows flow upward. 7 When an optimizer pushes a predicate down, it moves the filter closer to the leaves, which means earlier in execution. Down the tree is up the call stack.
Selections and projections move below the joins, so the join reads fewer rows and fewer columns. 1 The move stays legal only when the predicate names columns from one side of the join. A predicate that needs both sides has to wait.
The "for" half shows up as vectorized execution. Operators process a batch at a time, so the per-call dispatch is paid once per batch. DuckDB, for one, runs its operators on vectors of 2,048 tuples by default. 8

The algebra decides what is legal

The common advice "filter before you map" hides a trap. Written out, the two programs apply the predicate to different values:
filter p (map f xs)   -- p inspects the output of f
map f (filter p xs)   -- p inspects the input of f
The first p has type b -> Bool; the second has type a -> Bool. Swapping the order swaps what the predicate sees, so the two expressions describe different programs.
The law that connects them restates the second one with the predicate adjusted:
filter p . map f == map f . filter (p . f)
The proof runs through Maybe. filter can be built from a step that keeps or drops each element and a step that collects the survivors. catMaybes :: [Maybe a] -> [a] does the collecting. 9
keep :: (a -> Bool) -> a -> Maybe a
keep p x = if p x then Just x else Nothing

filter p = catMaybes . map (keep p)
catMaybes works at every element type at once, which is what makes it a natural transformation: mapping a function over the lists commutes with collecting the Justs. 10 filter p fixes its element type, so the naturality has to live in catMaybes.
With the factorization in place, the law is a short calculation. 1
filter p . map f
  == catMaybes . map (keep p) . map f
  == catMaybes . map (keep p . f)
  == catMaybes . map (fmap f . keep (p . f))
  == catMaybes . map (fmap f) . map (keep (p . f))
  == map f . catMaybes . map (keep (p . f))   -- naturality of catMaybes
  == map f . filter (p . f)
A square diagram. Top left is A, top right is B, bottom left is A-prime, bottom right is B-prime. The top and bottom edges are labelled map f, the left edge filter (p . f), the right edge filter p.
A square diagram. Top left is A, top right is B, bottom left is A-prime, bottom right is B-prime. The top and bottom edges are labelled map f, the left edge filter (p . f), the right edge filter p.
Our own diagram, not from the post. The two routes from [A] to [B'] agree: map and then filter on the upper route, filter on p . f and then map on the lower. The bottom-left corner is the subset of inputs that satisfy p . f; the bottom-right corner is the subset of f's outputs that satisfy p.

What the law buys

Both routes make one pass over the list, so the rewrite leaves the complexity alone. What changes is how often f runs, and only when the test itself gets cheaper.
filter (p . f) still calls f on every element, because it has to compute the test. 1 The rewrite pays when p . f collapses into something cheap on the input, which usually happens when p reads a field that f passes through untouched.
A concrete case: people.map(toDto).filter(dto => dto.age >= 18) becomes people.filter(p => p.age >= 18).map(toDto). The rewrite is legal when toDto carries age over unchanged, and then toDto runs only on adults. If p needs a value that f computes, the composite costs as much as f and the rewrite saves nothing.

Where it stops

Ghosh ends with the conditions. Taking an if out of a loop requires a loop-invariant condition. A condition that varies per element can leave the loop only by moving to the boundary and being recorded in a type, as Walrus replaces Option<Walrus>. 1
A selection can move below a join only when its predicate reads columns from one side. Filtering before mapping is legal by the law above, and saves work only when p . f simplifies.
The "fors down" half rests on cost; the filter/map half rested on equivalence. Changing an arrow from A -> B to [A] -> [B] pays the setup cost once per batch, and a batch of one pays it every time. 1

Applying it

  1. Who knows the answer? If every caller already holds the information the branch tests, move the branch up and let the parameter's type carry the result. If the callee is the only place that knows, the branch stays.
  2. Is the condition invariant? A condition that holds for the whole loop can leave it. A condition on the element can move to the boundary and become a type.
  3. Does the rewritten test cost less than the work it avoids? Compare p . f with f. When the test costs as much as the work, the rewrite is legal and pointless.
The essay's move is to treat each half of the rule as a rewrite with a side condition. The side condition decides; the style guide only recommends.

Verbatim quotes

"If you think a bit, the push-ifs-up-fors-down principle has far broader applications."
"So, it's the algebra that tells you which rewrites are legal. In the filter/map case above, it's the naturality of catMaybes that drives the legality and helps you reason about the overall structure of the code."
"For the "fors-down" part, it's more about the cost than the equivalence. You change the shape of the arrow from A -> B to [A] -> [B] so that you pay the setup cost once per batch."
— Debasish Ghosh, Push Ifs Up and Fors Down: The Idiom, Its Algebra, and Its Limits

References

  1. 1
    Push Ifs Up and Fors Down: The Idiom, Its Algebra, and Its Limits

    debasishg.github.io

  2. 2
    July in TigerLand

    tigerbeetle.com

  3. 3
    Blog — Ruminations of a Programmer

    debasishg.github.io

  4. 4
    Push Ifs Up And Fors Down

    matklad.github.io

  5. 5
    Coproduct

    en.wikipedia.org

  6. 6
    Subobject

    en.wikipedia.org

  7. 7
    Query optimization

    en.wikipedia.org

  8. 8
    Execution Format – DuckDB

    duckdb.org

  9. 9
    Data.Maybe

    hackage.haskell.org

  10. 10
    Natural transformation

    en.wikipedia.org

Functional Programming Deep Dives
Functional Programming Deep Dives@Gidon Kaminer

This story was produced automatically by a channel. One sentence is all it takes for Neodrop to keep producing for you.

Related content

  • What the rule says
  • Moving the if is a type change
  • The same move in a query planner
  • The algebra decides what is legal
  • What the law buys
  • Where it stops
  • Applying it
  • Verbatim quotes