Partial application means supplying some of a function's arguments now and getting back a new function that takes the rest later. If price(rate, qty, discount) computes a price, then fixing rate and discount produces a function from quantity to price. You use it constantly in Scala, often without naming it: every xs.map(_ * 2) and every method with a configuration parameter list called without its last list is partial application.

The idea is simple, but Scala gives you two mechanisms with different rules: the underscore placeholder, which is purely syntactic, and partial application of a parameter list, which goes through eta-expansion. They differ in how far the underscore reaches, in what types the compiler can infer, and, most subtly, in when the arguments you fixed are evaluated. This article covers both for Scala 2.13 and Scala 3, then shows how to use partial application to wire configuration into code.

Advertisement

Three ideas that get confused

Partial application fixes some arguments of a function and returns a function of the remaining ones. The result has fewer parameters.

Currying is a transformation of shape: a function of two arguments (A, B) => C becomes A => B => C, a function that takes one argument and returns another function. Currying does not fix anything; it makes partial application convenient, because applying a curried function to its first argument is partial application. Scala exposes this with .curried on function values and with multiple parameter lists on methods.

PartialFunction is unrelated despite the name. A PartialFunction[A, B] is a function defined only for some inputs, with isDefinedAt telling you which; it is what a block of case clauses creates and what collect consumes. Partially applied functions are total over their remaining parameters. Keep the two separate in your vocabulary and in code reviews.

Placeholder syntax

The underscore placeholder turns an expression with holes into an anonymous function. Each underscore becomes a fresh parameter, in left-to-right order.

def price(rate: Double, qty: Int, discount: Double): Double =
  rate * qty * (1 - discount)

// Fix rate and discount; leave qty open. The result is a function value.
val retail: Int => Double = price(9.99, _, 0.0)      // expected type gives _ its type
val bulk = price(7.50, _: Int, 0.15)                 // or ascribe the placeholder

retail(3)   // 29.97
bulk(100)   // 637.5

// Several holes become parameters in left-to-right order.
val atRate: (Int, Double) => Double = price(9.99, _, _)
atRate(2, 0.5)   // 9.99

The compiler must know each placeholder's type. It gets it from an expected type, as in val retail: Int => Double = ... or when the expression is an argument to map on a List[Int], or from an explicit ascription _: Int. Inference of placeholder types without either has differed between compiler versions, so in code meant to compile on both Scala 2.13 and Scala 3, write one of the two. It also documents intent. For how expected types flow, see Scala type inference.

Each underscore is a different parameter. _ * _ is (a, b) => a * b, not a square. If you need the same value twice, write a named lambda.

Advertisement

How far an underscore reaches

The rule in the language specification is that a placeholder expands at the smallest enclosing expression, in the grammar's sense, that properly contains it. Method arguments are such expressions; operands of an infix operator are not. That is why these behave differently:

val xs = List(1, 2, 3)
def f(x: Int): Int = x * 10

xs.map(_ + 1)            // x => x + 1                     fine
xs.map(math.abs(_))      // x => math.abs(x)               fine: the argument expression binds it
xs.map(f(_) + 1)         // x => f(x) + 1                  infix operands are not separate expressions

// The surprise: the placeholder binds to the smallest enclosing *expression*,
// and a method argument is an expression.
xs.foreach(println(_ * 2))   // means xs.foreach(println(x => x * 2)), not x => println(x * 2)
                             // it does not compile: the lambda's parameter type is unknown
xs.foreach(x => println(x * 2))   // write the lambda explicitly instead

The println(_ * 2) case is the classic trap: the underscore stops at the argument of println, so the code tries to print a function. When an underscore sits inside a nested call, write the lambda out. A useful review rule is that placeholders are for one short operation directly in the argument position of a higher-order method; anything longer gets a named parameter. Higher-order functions covers the methods that consume these function values.

Multiple parameter lists and eta-expansion

A method can declare several parameter lists. Calling it with only the first lists, and asking for a function, partially applies it. The compiler converts the remaining method into a function value through eta-expansion: wrapping the method call in a lambda.

enum Level { case Debug, Info, Warn }   // Scala 3 syntax; use a sealed trait in 2.13

def log(level: Level)(msg: String): Unit =
  println(s"[$level] $msg")

// Scala 3: a method with remaining parameters is eta-expanded automatically.
val warn = log(Level.Warn)              // warn: String => Unit
warn("disk 91% full")

// Scala 2.13: there is no expected function type, so write one or use the trailing underscore.
val warn2: String => Unit = log(Level.Warn)
val warn3 = log(Level.Warn) _            // accepted in Scala 3 too, but slated for deprecation

// Configuration first, data last: the shape that makes partial application useful.
def withRetry[A](attempts: Int, backoffMs: Long)(op: () => A): A = ???
val resilient = withRetry[String](3, 200)   // (() => String) => String, reusable everywhere

Scala 3 eta-expands automatically whenever the remaining method has an argument list with at least one parameter, so log(Level.Warn) simply has type String => Unit. The explicit trailing underscore m _ still compiles, but the Scala 3 reference describes it as no longer necessary and slated for deprecation. Scala 2.13 expands automatically only when a function type is expected, so there you either annotate the val or keep the trailing underscore. Methods with an empty parameter list are stricter in Scala 3: they are expanded only when a type such as () => T is expected. Eta-expansion in general, including what happens to overloads, is covered in Scala methods versus functions.

Parameter order is a design decision. Put what varies least, configuration and dependencies, in the first list, and what varies most, the data, last. Then partially applying the first list gives a reusable function, and the data list can also be written with a block argument: resilient { () => fetch(url) }.

When the fixed arguments are evaluated

Two ways to fix some arguments, and when each one evaluates themPlaceholder syntaxprice(rate(), _: Int)Partial application of a parameter listprice2(rate()) (Scala 3 auto eta)Expands to a lambdaq => price(rate(), q)Eta-expansion lifts the argumentval r = rate(); q => price2(r)(q)rate() runs on EVERY callfresh value each timerate() runs ONCEcaptured value reusedBoth produce an Int => Double. Only the evaluation of the fixed argument differs.If it matters, bind the value to a val yourself so the intent is visible in the code.
Placeholder syntax is rewritten into a lambda, so the fixed argument expression runs on every call. Eta-expansion first lifts non-value arguments into vals, so they run once.

This difference is rarely taught and causes real bugs. A placeholder expression is a syntactic rewrite into a lambda, so every argument expression you wrote stays inside the lambda body and runs every time the function is called. Eta-expansion, by contrast, is specified in the Scala 2.13 language specification to evaluate argument expressions that are not simple values first, bind them to fresh vals, and capture those, so they run once, when the function value is created.

var calls = 0
def rate(): Double = { calls += 1; 1.5 }

def price(r: Double, qty: Int): Double = r * qty
def price2(r: Double)(qty: Int): Double = r * qty

val viaPlaceholder: Int => Double = price(rate(), _)   // q => price(rate(), q)
val viaEta: Int => Double = price2(rate())  // eta-expansion; the expected type makes it compile on 2.13 too

// After construction: calls == 1 (eta-expansion lifted rate() into a val)
viaPlaceholder(1); viaPlaceholder(2)          // calls == 3: placeholder re-evaluates each time
viaEta(1); viaEta(2)                          // calls still 3: captured value reused

// Make intent explicit and version-proof:
val r = rate()
val explicit = price(r, _: Int)

Both behaviours can be what you want. Re-evaluation is right for now() or for reading a mutable setting. Evaluating once is right for an expensive lookup, a connection, or a random seed that must stay fixed. The danger is not knowing which you have, for example refactoring a curried method into a single parameter list and switching from once to every call without noticing. The robust habit is to bind any non-trivial argument to a val before partially applying, which makes the evaluation point obvious and independent of syntax and compiler version. Captured values follow the normal closure rules described in Scala closures, including what happens when they capture a var.

What a function value cannot carry

A method is richer than a function value. When you partially apply a method, several features do not survive the conversion.

  • Default arguments. def connect(host: String, port: Int = 5432) can be called with one argument, but the function value from connect(_, _) requires both. If you want the default, apply it before expanding: connect(_, 5432).
  • Named arguments. Function values have positional parameters only. f(port = 1) works on a method, never on a Function2.
  • Type parameters. A function value is monomorphic in Scala 2: the type parameters must be fixed at the point of expansion, as in withRetry[String](3, 200). Scala 3 adds polymorphic function types, but unless you ask for one with an explicit expected type, eta-expansion fixes the type parameters.
  • Implicit and using parameters. They are resolved at expansion time and do not become parameters of the function (Scala 3 can instead produce a context function if you write that type as the expected type), so the instance in scope where you partially apply is the one captured.
  • By-name parameters. They become => A parameters of the resulting function type, which keeps laziness, but the expansion can surprise readers. Prefer an explicit () => A in APIs designed for partial application.

Worked example: wiring configuration once

A common real use is separating wiring from work. Each business rule takes its configuration in a first parameter list and the data in the last. At startup you apply the configuration and get plain functions; the hot path and the tests see only those functions.

final case class Order(id: String, country: String, subtotal: BigDecimal, items: Int)

def taxFor(rates: Map[String, BigDecimal])(o: Order): BigDecimal =
  o.subtotal * rates.getOrElse(o.country, BigDecimal(0))

def shipping(perItem: BigDecimal, freeOver: BigDecimal)(o: Order): BigDecimal =
  if (o.subtotal >= freeOver) 0 else perItem * o.items

def total(tax: Order => BigDecimal, ship: Order => BigDecimal)(o: Order): BigDecimal =
  o.subtotal + tax(o) + ship(o)

// Wiring happens once, at startup, from configuration:
val euTax  = taxFor(Map("DE" -> BigDecimal("0.19"), "FR" -> BigDecimal("0.20")))
val euShip = shipping(BigDecimal("2.50"), BigDecimal("50"))
val euTotal: Order => BigDecimal = total(euTax, euShip)

// The hot path sees only Order => BigDecimal:
orders.map(euTotal)

// In tests, swap a piece without touching the others:
val noShip = total(euTax, _ => BigDecimal(0))

This is dependency injection without a framework. The functions are easy to test because each piece can be replaced by a lambda, as noShip shows. The configuration is read once, and because the fixed arguments here are values rather than calls, there is no evaluation ambiguity. The resulting Order => BigDecimal composes with map, andThen and the folds described in fold, map and filter.

Cost and performance

Every partial application allocates a function object that holds the captured values, and every call through it is an interface call on Function1 or a sibling. In ordinary code this cost is negligible and the JIT compiler often inlines it. It matters in tight loops over primitives, because values pass through generic function interfaces and may be boxed, and in code that partially applies inside the loop, allocating a new closure per element. Apply outside the loop and reuse the function value. If profiling shows boxing in a hot path, write a direct method there and keep partial application for wiring.

Common mistakes

MistakeSymptomFix
Underscore inside a nested callCompile error or a function printed instead of a valueWrite the lambda explicitly
Expecting _ * _ to squareFunction of two argumentsUse x => x * x
Fixed argument is a side-effecting call in placeholder syntaxEffect repeats on every callBind it to a val first
Relying on a default after expansionArity mismatchSupply the default before expanding
Data first, configuration lastPartial application yields nothing reusableReorder parameter lists: configuration first
Calling it PartialFunctionConfused reviews and wrong typesUse the distinct names consistently

What to do next

  1. Find three placeholder lambdas in your codebase with a nested call around the underscore and rewrite them as named lambdas.
  2. Pick one service with configuration threaded through many calls and move the configuration into a first parameter list, applying it once at startup.
  3. Search for partially applied expressions whose fixed arguments are method calls and bind each to a val, so evaluation happens once and visibly.
  4. If you cross-compile for Scala 2.13 and 3, add expected types or ascriptions to placeholders, and replace m _ with annotated vals.
  5. Write a small test that counts evaluations, like the example above, to confirm the behaviour on your compiler version.
  6. Read the methods-versus-functions article to understand eta-expansion of overloaded and nullary methods.
Key takeaway: Partial application fixes some arguments and returns a function of the rest. Scala offers placeholder syntax, a purely syntactic rewrite into a lambda that reaches only to the smallest enclosing expression and re-evaluates its fixed arguments on every call, and partial application of parameter lists through eta-expansion, which Scala 3 performs automatically and which evaluates non-value arguments once. Give placeholders a type, write lambdas when an underscore would sit inside a nested call, bind non-trivial arguments to vals, order parameter lists configuration first, and keep PartialFunction a separate idea.