The Limits of Functional Programming: Performance, Readability and Pragmatic Team Use
The cost of functional programming in C# is usually not where people expect: measured, LINQ is nanoseconds slower than a loop for a small cart, Option's cost comes from closures more than from being a struct or a class, and carrying expected errors as exceptions is a hundred times more expensive than Result. We show the BenchmarkDotNet results, stack traces in composition, which ideas pay off in a team, F#, and where Bookstore ended up.
Version note: The code in this post was built and run with C# 14 and .NET 10 (SDK 10.0.402), the measurements with BenchmarkDotNet 0.15.8, and the F# example with F# 10 and F# Interactive from the .NET 10 SDK.
Guess First: LINQ or a Loop?
Two comments in a code review of Bookstore, the online bookstore used as the shared example across this site's series, started a discussion. The first was about the line that computes the cart subtotal: "LINQ is slow, write this with foreach."
[Benchmark(Baseline = true)]
public decimal Loop()
{
decimal subtotal = 0m;
foreach (CartLine line in _lines)
{
if (line.Quantity > 0)
subtotal += line.LineTotal;
}
return subtotal;
}
[Benchmark]
public decimal Linq() => _lines.Where(line => line.Quantity > 0).Sum(line => line.LineTotal);
The second was about the coupon calculation from the sixth post: "Option creates a new value at every step; how many times more expensive are all these lambdas and Options than plain if statements?" We measured three versions: classic code written with null checks, CouponLookup.CouponDiscount written with the struct Option<T>, and the same chain written with an Option that is a class. How many times slower than the loop is LINQ for a three-line cart and for a 1000-line cart? How many bytes does the Option version of the coupon calculation allocate?
The Answer: You Don't Know Until You Measure
$ dotnet run -c Release --project benchmarks/Bookstore.Fp.Benchmarks -- --filter '*'
| Method | Lines | Mean | Ratio | Allocated |
|------- |------ |-------------:|------:|----------:|
| Loop | 3 | 6.416 ns | 1.00 | - |
| Linq | 3 | 17.624 ns | 2.75 | 48 B |
| Loop | 1000 | 3,003.717 ns | 1.00 | - |
| Linq | 1000 | 4,571.002 ns | 1.52 | 48 B |
| Method | Code | Mean | Ratio | Allocated |
|------------------- |----------- |---------:|------:|----------:|
| NullChecks | autumn10 | 17.26 ns | 1.00 | 80 B |
| StructOption | autumn10 | 24.97 ns | 1.45 | 152 B |
| StructOptionInline | autumn10 | 19.99 ns | 1.16 | 80 B |
| ClassOption | autumn10 | 21.84 ns | 1.27 | 80 B |
The tables come from BenchmarkDotNet's report; we removed the environment information and the Error, StdDev and Gen0 columns to save space. The measurements were taken on a developer machine; the absolute times will differ on yours, so look at the ratios and the allocations.
LINQ really is slower: 2.75 times for a three-line cart, 1.52 times for a 1000-line cart. But look at the absolute difference: 11 nanoseconds for a three-line cart. A checkout request goes to the database and the payment provider and spends milliseconds; the gap is about five orders of magnitude. The ratio drops for the big cart because the fixed cost (setting up the iterator and delegate calls, the 48-byte allocation) is spread over the lines, and because the decimal addition itself is expensive. The first review comment isn't justified for this code; if the same calculation ran over millions of lines in a report, thousands of times a second, it would be worth measuring and discussing again.
The Option table has something that doesn't match the first guess. CouponLookup.CouponDiscount (struct Option) allocates 152 bytes and runs 1.45 times slower than the null-checking code, while StructOptionInline, the same chain written inside the benchmark method, stays at 80 bytes and 1.16 times. The class version of Option is at 80 bytes too. All 80 bytes are the strings produced by Trim() and ToUpperInvariant(); the null-checking code produces them as well. So Option itself adds no allocation here; the difference comes from where the chain lives. The lambdas in CouponLookup capture method parameters (campaigns, now, subtotal), and those closures are created on every call; the lambdas inside the benchmark method only use this and constants. That the class Option's Some values don't end up on the heap is consistent with the .NET 10 JIT's escape analysis; the .NET 10 runtime what's-new page on Microsoft Learn describes this analysis being extended to delegates and small objects. We didn't examine one by one which allocation was removed and why; that side of the JIT belongs to the Compilers and Programming Languages series. This is also the answer to the "struct or class" exercise from the sixth post: at this scale what decides is not the kind of type but the closures and whether the JIT can inline the code.
Where Allocations Come From: Closures, Delegates and Copies
The cost of the functional style mostly comes from four places, and we've seen all four during the series.
Closures and delegates. When a lambda captures an outside variable, the compiler creates a class that holds the variable (a closure) and a delegate. In the fourth post's measurement (04-alloc), recreating the rule for every element of 1000 prices allocated 96,000 bytes, while creating it once and reusing it allocated 96 bytes. The rule: build the delegate once, outside the loop.
Immutable copies. In the second post (02-cost), adding one element to a 10,000-element collection cost 80,032 bytes with ImmutableArray and 696 bytes with ImmutableList, which uses structural sharing. The cost of immutability depends on the data structure; which structure to choose when is covered in the C# Collections series post "Immutable Collections in C# and Choosing the Right Collection".
Option and Result wrappers. The table above showed that being a struct or a class doesn't decide much at this scale. The Result<T> from the earlier posts is a class; every step produces a new Result, and a failed validation allocates 184 bytes. But the comparison has to be fair: the alternative to Result is usually not nothing, it's an exception.
| Method | Quantity | Mean | Ratio | Allocated |
|-------------- |--------- |------------:|------:|----------:|
| ResultFailure | 0 | 20.26 ns | 1.00 | 184 B |
| ThrowAndCatch | 0 | 2,012.10 ns | 99.30 | 432 B |
Applying the same quantity rule by throwing and catching an ArgumentOutOfRangeException is about a hundred times slower and allocates more than twice as much. That's not a reason to ban exceptions; unexpected errors are rare and the value of the stack trace outweighs this cost. But carrying expected validation errors with exceptions on every form submission is a measurable cost. The "expected business error is a Result, unexpected failure is an exception" split from the seventh post has a performance side too.
Lazy evaluation and multiple enumeration. LINQ's deferred execution runs a query again every time it's enumerated; in the fourth post's example (04-enum), a two-line cart triggered four price calculations. The cost there isn't allocation but repeated work.
Debugging Deep Composition
The readability gain of composition comes back as a cost in the stack trace when something goes wrong. Let's put a broken rule in the middle of a rule chain built with the fourth post's PriceRules.Combine:
// A rule with a bug, hidden in the middle of a composed pipeline
Func<decimal, decimal> brokenRule = amount => amount / (amount > 200m ? 0m : 1m);
Func<decimal, decimal> checkout = PriceRules.Combine([PriceRules.PercentOff(0.10m), brokenRule, PriceRules.AmountOff(15m), PriceRules.RoundToKurus]);
try
{
Console.WriteLine(checkout(249.40m));
}
catch (DivideByZeroException ex)
{
// Strip file paths so the output is the same on every machine
foreach (string frame in ex.StackTrace!.Split(Environment.NewLine))
Console.WriteLine(Regex.Replace(frame, @" in .*$", ""));
}
$ dotnet run --project src/Bookstore.Fp.Demo -- 09-trace
at System.Decimal.DecCalc.VarDecDiv(DecCalc& d1, DecCalc& d2)
at System.Decimal.op_Division(Decimal d1, Decimal d2)
at Bookstore.Fp.Demo.Post09.<>c.<Trace>b__0_0(Decimal amount)
at Bookstore.Ordering.Functional.FuncExtensions.<>c__DisplayClass0_0`3.<Then>b__0(TIn input)
at Bookstore.Ordering.Functional.FuncExtensions.<>c__DisplayClass0_0`3.<Then>b__0(TIn input)
at Bookstore.Ordering.Functional.FuncExtensions.<>c__DisplayClass0_0`3.<Then>b__0(TIn input)
at Bookstore.Fp.Demo.Post09.Trace()
The stack trace doesn't say which rule blew up. The broken lambda got the name <>c.<Trace>b__0_0; the classes the compiler generates for closures show up as <>c__DisplayClass0_0, and Then repeats three times. In an imperative CalculateTotal, the same error would say "line 27". Someone who sees this stack trace in a production log can't find the error without knowing the order of the rules.
You don't have to give up the functional style for this; a few habits are enough. Write rules as named methods or factories: even a lambda returned from a factory like PercentOff carries the factory's name in the stack trace (in a separate test, a factory called DivideBy showed up as the frame Rules.<>c__DisplayClass0_0.<DivideBy>b__0). Keep chains short; splitting a ten-step pipeline into named intermediate values makes it easier both to set breakpoints in the debugger and to read the stack trace. In railway chains, logging intermediate values with Tap (in the shell) and putting the step's name in the error (codes like stock.insufficient) does the same job. Don't forget that IDEs can set breakpoints inside lambdas; Visual Studio and Rider can stop at a lambda in the middle of a line on its own.
Readability and the Team: Which Ideas Always Pay Off?
The ideas in this series don't all carry the same weight. Separating the ones that pay off in code review without argument from the ones that depend on the team and context is how functional programming lasts in a team:
- Always pay off: immutable DTOs and value objects (records,
init,with); keeping the calculations that make decisions pure and pushing the clock and I/O out to the shell; modelling states with sum types instead of flags (abstract records in C# 14,closedorunionin C# 15); returning expected business errors with Result; readable LINQ such asSelect,Where,Sum. - Depend on context: making every method return Option or Result; point-free style (building functions with
ThenandComposewithout ever naming the argument); using currying in everyday APIs; async railway chains; defining custom operators; spreading a broad library like language-ext across the whole codebase.
What the second list has in common is that reading the code requires learning an extra concept. A newcomer to the team already knows Select, record and pattern matching; they don't know Bind, Apply, Either<L, R> and monad transformers. A library like language-ext is powerful, but choosing it means adding its own concepts and names (Either, Fin, Eff and so on) to the language of the codebase; a developer who doesn't know that library spends their first week learning the library, not the code. A practical yardstick: if you can't explain a functional construct in two sentences in a code review, it's too early for the team as a whole.
There's a general rule in the readability debate too: the functional style makes business rules easier to read because it puts the "what" first, but it can hide the "how" and "when" (in what order, how many times it ran, where it allocated). LINQ's deferred execution and the multiple enumeration example in the fourth post are the typical case. Write declaratively when the calculation itself matters; when its timing and cost matter, keep them visible in the code.
A Short Look at F# and Haskell
The ideas in this series mostly came to C# from F# and Haskell. The same Bookstore rules in F# show how the language makes these ideas the default:
// The same Bookstore rules in F#: records, a discriminated union, composition with >> and |>
type CartLine = { Isbn: string; Title: string; UnitPrice: decimal; Quantity: int; IsEbook: bool }
type OrderStatus =
| Pending
| Paid of paymentId: string
| Shipped of paymentId: string * trackingNo: string
| Cancelled of reason: string
let percentOff rate amount = amount * (1m - rate)
let amountOff discount amount = max 0m (amount - discount)
let roundToKurus (amount: decimal) = System.Math.Round(amount, 2, System.MidpointRounding.AwayFromZero)
// Coupon first, then the member discount, then rounding
let checkoutRules = percentOff 0.10m >> amountOff 15m >> roundToKurus
let cart =
[ { Isbn = "9780441013593"; Title = "Dune"; UnitPrice = 189.50m; Quantity = 1; IsEbook = false }
{ Isbn = "9780141439587"; Title = "Emma"; UnitPrice = 59.90m; Quantity = 1; IsEbook = true }
{ Isbn = "9780261103344"; Title = "The Hobbit"; UnitPrice = 149.90m; Quantity = 0; IsEbook = false } ]
let subtotal =
cart
|> List.filter (fun line -> line.Quantity > 0)
|> List.sumBy (fun line -> line.UnitPrice * decimal line.Quantity)
printfn "subtotal: %M, after rules: %M" subtotal (checkoutRules subtotal)
let describe status =
match status with
| Pending -> "pending"
| Paid paymentId -> $"paid with {paymentId}"
| Shipped (_, trackingNo) -> $"shipped with {trackingNo}"
| Cancelled reason -> $"cancelled: {reason}"
[ Pending; Paid "PAY-1001"; Shipped("PAY-1001", "TRK-778"); Cancelled "customer request" ]
|> List.iter (describe >> printfn "%s")
$ cd fsharp
$ dotnet fsi Discount.fsx
subtotal: 249.40, after rules: 209.46
pending
paid with PAY-1001
shipped with TRK-778
cancelled: customer request
209.46 is the result we got with PriceRules.Combine in the fourth post. Look at the differences: records are immutable by default and copying is written { line with Quantity = 2 }; every function is curried automatically, so percentOff 0.10m is a partial application as is; >> is the language's version of our Then extension and |> of Pipe. OrderStatus is a five-line discriminated union and the compiler warns when you forget an arm (this part was tried in a separate script):
Short.fsx(8,11): warning FS0025: Incomplete pattern matches on this expression. For example, the value 'Cancelled (_)' may indicate a case not covered by the pattern(s).
Because F# runs on the same runtime as C#, the two can sit side by side in one solution: some teams write the domain core in F# and the API and infrastructure in C#. Haskell goes a step further: all functions are pure by default, side effects show up in the type (IO) and evaluation is lazy. No Haskell code was run in this series; for the curious, here's a roadmap: start with the F# documentation on Microsoft Learn and Scott Wlaschin's fsharpforfunandprofit.com, then Wlaschin's book Domain Modeling Made Functional (2018) for domain modelling, and after that Haskell, to see how a type system can enforce pure functions. Mathematical foundations such as category theory belong at the end of this road, not at its start.
Closing Bookstore: From LegacyCheckout to a Functional Core
In the first post of the series, LegacyCheckout.CalculateTotal computed a cart total while deleting lines from the caller's list, writing to the console, leaving the last total in a static field and tying the result to the machine's clock. Nine posts later the same job is split into a few small pieces:
| Topic | LegacyCheckout (first post) | End of the series |
|---|---|---|
| Code | a single 39-line class | CheckoutCalculator (32), CheckoutService (15), PriceRules (20), CouponLookup (31) |
| Hidden input | DateTime.Now, a fixed coupon code and date | the clock via TimeProvider in the shell; the campaign as a parameter |
| Changing the input | deletes lines from the caller's list | Cart is immutable; empty lines are filtered |
| Side effects | Console.WriteLine and the static LastTotal | logging only in CheckoutService and OrderPlacement |
| Order status | three bools and three nullable properties | the OrderStatus sum type; closed in C# 15 |
| "Nothing" and errors | null, 0 and bool | Option and Result |
| Tests | can't be tested without changing the clock | 39 tests; the core tests need no clock and no fakes |
$ dotnet test --solution Bookstore.Fp.slnx
Test run summary: Passed!
total: 39
failed: 0
succeeded: 39
skipped: 0
To be honest, the code didn't shrink, it grew. In place of the 39-line method there's now a pricing core of about 100 lines; with Option, Result and the order flow, Bookstore.Ordering is 490 lines. Some of those lines are new work (coupon normalisation, stock reservation, validation), and some are infrastructure that C# doesn't offer in the language and we wrote ourselves (Option<T>, Result<T>, Then, Combine). What we gained isn't a line count: each rule can be read and tested on its own, "what happens on 1 December?" is answered by a [Theory] row without moving the clock, and adding an order status or an error code produces a compiler warning. In the first post, understanding LegacyCheckout meant reading the whole body and keeping the order in your head; now the signature of CheckoutCalculator.Calculate says what it does.
Common Pitfalls
Debating performance by guesswork. The first two comments in this post were two assumptions made without measuring. LINQ is 11 nanoseconds slower than the loop for a three-line cart; the Option version of the coupon calculation is at most 8 nanoseconds slower than the null-checking one. Carrying a validation error with an exception, on the other hand, is a hundred times more expensive; so intuition was looking in the wrong place for two of these three questions. Before rejecting a change on performance grounds, measure it with BenchmarkDotNet, and measure code that really runs often. The detailed method is in the LINQ series post "LINQ Performance: Multiple Enumeration, Materializing and Measuring with BenchmarkDotNet" and the C# Advanced series post "Performance in C#: Allocations, Structs and Measuring with BenchmarkDotNet".
Using functional abstractions blindly on a hot path. Creating a closure and an Option at every step of a parse loop that runs hundreds of thousands of times a second is a measurable cost. There, Span<T>, a loop and early returns are the right tools; you can still offer a pure function signature to the outside. Purity is a property of the interface: a loop that updates a local variable inside doesn't make a function impure from the outside.
Deciding on a library for the team on your own. Bringing a functional programming library into the codebase with one PR means changing the language everyone will read. Try it in a small module first, see that the team can read it comfortably in code review, then spread it.
Making functional programming a matter of identity. The goal isn't to write functional code but to write code that surprises less and is easier to test. If a class, a foreach or an if is clearer, write that.
A Small Experiment
★☆☆ Add Lines = 100_000 to SubtotalBenchmarks and run it again. How does the ratio between LINQ and the loop change as the cart grows? Does Bookstore really have a 100,000-line cart, or is this measurement a question for another screen (reports)?
★☆☆ Turn the brokenRule lambda in Post09.Trace into a method called private static decimal DivideBySomething(decimal amount) and read the stack trace again. What changed?
★★☆ Add a Delivered case to the F# script and read the warning from describe. Then add an addVat 0.10m step that adds VAT to checkoutRules; do the same in C# with the fourth post's PriceRules and compare the two pieces of code.
★★★ Write a "functional programming code review checklist" for your team: which items from the two lists in this post would you make rules, and which recommendations? Try the list in a PR on a real change.
This is where the series ends. Over these nine posts, Bookstore's checkout and order code turned from a single method with hidden inputs and flags into small, pure, tested functions; along the way we used tools C# and TypeScript already have and added C# 15's sum types. For anyone who wants to keep going there are two natural paths: the composition of RxJS operators in the upcoming RxJS series, and techniques for moving an existing codebase in this direction step by step in the upcoming Refactoring series.