Functions in depth: keyword arguments, do blocks and closures

Design a function's signature, distinguish positional from keyword arguments, pass behaviour with do blocks, and understand what a closure captures.

Signature design

# positional: part of the identity of the call
# keyword: named options with defaults, after the semicolon
function fit(x, y; method = :ls, tol = 1e-8, maxiter = 1000)
    (; method, tol, maxiter)
end

fit(1:10, 1:10)                       # all keywords default
fit(1:10, 1:10; method = :huber)      # one keyword overridden

# varargs
total(args...) = sum(args)
count_types(xs...) = length(xs)
print_all(io, xs...) = foreach(x -> println(io, x), xs)

# optional positional arguments are really extra methods
greet(name) = "hello " * name
greet(name, title) = "hello " * title * " " * name
  • Default positional arguments are not a separate feature: each default creates another method.
  • Keyword arguments are gathered into a NamedTuple, so they add a small allocation and are not part of dispatch.
  • Dispatch happens on positional arguments only. Two methods that differ only in keyword names are the same method.

do blocks and anonymous functions

map(x -> x^2, 1:5)                      # anonymous
map(x -> x^2, 1:5) |> sum

# do syntax: the block becomes the FIRST argument
open("data.txt", "w") do io
    write(io, "hello\n")
end

# equivalent to
open(io -> write(io, "hello\n"), "data.txt", "w")

sort([3, 1, 2]; by = x -> -x)            # a named keyword taking a function
filter(iseven, 1:10)                     # a function passed positionally

The do form exists because the first argument is where callback-taking functions put their function. Any function whose first parameter is a function can be called with a trailing block, which is why open, map and lock all read naturally.

Closures and scope

function make_counter()
    n = 0
    () -> (n += 1)       # the closure captures n by reference
end

c = make_counter()
c(); c()

# let introduces a new binding, which is what you want inside loops
funcs = [let i = i; () -> i; end for i in 1:3]
[ f() for f in funcs ]   # 1, 2, 3 with let; 3, 3, 3 without it (in older versions)

# a captured variable that is reassigned becomes a boxed Ref: a performance trap
function slow_accumulate(v)
    total = 0
    for x in v
        total += x
    end
    total
end
💡
A closure that assigns to a captured variable forces Julia to box that variable, which allocates and can slow a hot loop by a large factor. If a loop variable is only read, there is no box; if it is written, keep the accumulation in the function's own scope rather than inside a nested closure.

FAQ

Should a function use keyword arguments for everything optional?
Use keywords for options that do not change what the function fundamentally does, such as tolerances and verbosity. Use positional arguments for things that are part of its identity, and add methods rather than flags when the behaviour differs substantially.
Why does my do block not compile?
The function being called must accept a function as its first positional argument. If it takes the callback last, or as a keyword, wrap the call yourself instead of using do.

Multiple dispatch Control flow, loops and comprehensions

Last refreshed 2026-09-18.