Control Flow
if in Julia is not a statement that "does something" — it is an expression that is something. Each branch is worth its last expression, and the block as a whole is worth the branch that ran. That single design decision removes the temporary variables, flag-setting, and duplicated assignments that statement-oriented languages force on you.
This lesson covers the branching vocabulary in the order you will need it: the if expression itself, the ternary operator and when chaining it becomes unreadable, guard clauses that flatten nested logic, the short-circuit operators used as control flow, and finally multiple dispatch — Julia's answer to the long if/elseif ladders that grow in other languages.
The if Expression
The syntax is Pascal-like: a keyword, a condition, then end. There are no parentheses around the condition and no braces around the body. The condition must be a Bool — Julia never coerces numbers, strings, or nothing into a truth value, so a whole class of C bugs simply cannot be written.
if, elseif, else
Branches are tested top to bottom and the first true condition wins; nothing after it is evaluated. Note the spelling: elseif is one word, not else if. (Unlike C, Julia has no switch; long ladders are usually better written with dispatch, which we meet later in this lesson.)
# A single branch
if 3 > 2
println("yes")
end
# Two branches
x = 7
if x > 10
println("big")
else
println("small") # ← runs, because 7 is not greater than 10
end
# A ladder. Note the keyword: elseif, one word.
score = 87
if score ≥ 90
println("A")
elseif score ≥ 80
println("B") # ← this branch runs
elseif score ≥ 70
println("C")
else
println("F")
end
The Value of a Branch
Because the whole block is an expression, you can bind its result directly. There is no need for the "declare a variable first, then assign inside each branch" pattern that statement languages require.
The block is worth the value of the branch that ran, so it can be assigned directly: grade = if … end.
score = 87
# Bind the block's value — no temporary variable needed.
grade = if score ≥ 90
"A"
elseif score ≥ 80
"B"
else
"F"
end
grade # "B"
# The same value can feed a function call directly.
describe(g) = "grade $g"
describe(if score ≥ 80 "pass" else "fail" end) # "grade pass"
This is why Julia has no switch statement that returns a value and no ternary-only idiom: any branching construct can already be used on the right-hand side of =.
What Counts as True
Only true and false are valid conditions. There is no "truthy" concept: 0, "", [], and nothing are all rejected with a TypeError rather than treated as false. The one exception is missing, which is allowed to reach the condition and raises a specific MissingException there — so you find out exactly where the absent data mattered.
if true println("ok") end # ok
if 1 end # ERROR: TypeError: non-boolean (Int64) used in boolean context
if "" end # ERROR: TypeError: non-boolean (String)
if nothing end # ERROR: TypeError: non-boolean (Nothing)
# Conditions are questions, so missing data is handled, not guessed at:
value = missing
if ismissing(value)
println("no reading")
end
# Bool(x) converts only 0 and 1; every other number is an error.
Bool(1) # true
Bool(0) # false
Bool(2) # ERROR: InexactError
The practical payoff is that conditions read as questions, not as arithmetic. if length(v) > 0 cannot be shortened to if length(v), and that is a feature: the comparison states the intent, and a reader cannot mistake an integer for a boolean.
Ternary and Chained Branches
The ternary operator condition ? a : b is the compact form of a two-branch if. In Julia it is genuinely an expression, so it nests inside other expressions, and it is the idiomatic way to choose between two values on a single line.
The ? : Operator
Both results must be usable together: if the branches produce different types, the compiler creates a small union and the code still runs. Prefer branches of the same type when performance matters.
x = 7
label = x > 0 ? "positive" : "non-positive" # "positive"
# Nesting builds a ladder; keep it shallow.
score = 87
grade = score ≥ 90 ? "A" :
score ≥ 80 ? "B" :
score ≥ 70 ? "C" : "F"
grade # "B"
# Branches may have different types, but the result is then a Union:
result = x > 0 ? x : "not applicable" # Union{Int64, String}
typeof(result) # Union{Int64, String}
Chained Conditions
A ternary chain is right-associative and evaluated lazily: only the branch that is selected evaluates. That makes it safe to put a potentially failing expression in a branch, and it is why the chain can express a "first available" rule.
# The ternary is lazy, so the division is never evaluated when n is 0.
n = 0
half = n == 0 ? 0 : n / 2 # 0 — the else branch never runs
# A "first defined wins" rule, written as a chain:
config = Dict(:timeout => 30)
timeout = haskey(config, :timeout) ? config[:timeout] :
haskey(ENV, "TIMEOUT") ? parse(Int, ENV["TIMEOUT"]) : 60
timeout # 30
When Not to Nest
Two levels of ternary are readable; four are not. Past that point the logic belongs in an if expression or, better, in a small function with early returns. A useful rule: if a reader has to trace the indentation to find which : pairs with which ?, rewrite it.
# ❌ Hard to scan — the colon alignment is doing the work the reader needs.
status = a ? b ? "x" : "y" : c ? "z" : "w"
# ✅ Same logic, but each branch is named and testable.
function status_of(a, b, c)
a && return b ? "x" : "y"
c && return "z"
return "w"
end
Guard Clauses and Short-Circuit Logic
Deeply nested if blocks are hard to read because the reader must hold every enclosing condition in mind. The guard-clause style inverts the nesting: the function rejects the cases it cannot handle first, then proceeds along a flat path. Since Julia's control flow is expression-based, this costs nothing in structure.
Early Return
A guard is a condition that exits the function immediately. Each guard removes one reason for the rest of the body to be nested. The alternative — a single if wrapping the whole body — adds an indentation level for every additional constraint.
# ❌ Nested: the happy path is buried three levels deep.
function area(r, allow_negative)
if r isa Real
if allow_negative || r ≥ 0
π * r^2
else
throw(ArgumentError("negative radius"))
end
else
throw(ArgumentError("radius must be a number"))
end
end
# ✅ Guarded: every rejection is one line, the happy path is flat.
function area(r, allow_negative = false)
r isa Real || throw(ArgumentError("radius must be a number"))
(allow_negative || r ≥ 0) || throw(ArgumentError("negative radius"))
return π * r^2
end
area(2.0) # 12.566370614359172
area(-1.0) # ArgumentError: negative radius
&& and || as Control Flow
Because && and || short-circuit and return an operand, they double as a compact control-flow form. cond && action means "do this only if"; cond || action means "do this unless". Both are idiomatic Julia when the body is a single expression.
verbose = true
verbose && println("debug: starting") # prints only when verbose is true
# Guard + throw in one line — the most common use of || as control flow.
n = -3
n ≥ 0 || throw(ArgumentError("expected a non-negative number"))
# Do not use it for side effects with multiple statements — use if.
if verbose
println("count: 3")
println("done")
end
cond && action requires cond to be a real Bool, so it cannot replace a nil check the way it does in JavaScript (x && x.field). For "if this is not nothing, use its field", use the pattern in the next subsection or an explicit if.
Defaults with coalesce and something
Absent data has two spellings in Julia, and each has its own substitution function. missing means "no value was recorded" and is replaced by coalesce(a, b). nothing means "there is no result" and is replaced by something(a, b). Using the wrong one is a silent bug, because the value passes through unchanged instead of being replaced.
# coalesce substitutes for MISSING
coalesce(missing, 30) # 30 — the default applies
coalesce(10, 30) # 10 — the real value survives
coalesce(false, true) # false — the real value survives
coalesce(nothing, 30) # nothing ⚠ NOT 30 — coalesce ignores nothing
# something substitutes for NOTHING
something(nothing, 30) # 30
something(nothing, nothing) # ArgumentError: all arguments to something were nothing
# Both replace the || idiom, which breaks when the legitimate value is `false`:
flag = false
flag || true # true — the default overwrote the real answer
# Reading an optional value with a fallback
d = Dict(:limit => 10)
get(d, :limit, 100) # 10 — get's own default, the simplest form
get(d, :other, 100) # 100
something(get(d, :other, nothing), 100) # 100 — when nothing must be chased down
The cleanest habit is to use get(dict, key, default) when a dictionary may lack a key, to reserve coalesce for data columns that carry missing, and to use something for functions that return nothing — findfirst and get are the usual sources.
Dispatching Instead of Branching
A long if/elseif ladder that inspects the type of its argument is a pattern Julia exists to eliminate. Multiple dispatch selects the method by argument types at compile time, so the "which kind of thing is this?" decision is made once, by the compiler, and never re-checked at run time.
Avoiding isa Checks
The type-first ladder below works, but it hides the behaviour of each type behind one long function and forces every future addition to edit it. Defining a method per type gives the same behaviour with the branches living next to the data they serve.
# ❌ A type ladder: every new shape edits this one function.
function area_naive(shape)
if shape isa Circle
π * shape.r^2
elseif shape isa Rectangle
shape.w * shape.h
else
throw(ArgumentError("unknown shape"))
end
end
struct Circle; r::Float64; end
struct Rectangle; w::Float64; h::Float64; end
# ✅ One method per type — Julia picks the right one from the argument type.
area2(c::Circle) = π * c.r^2
area2(r::Rectangle) = r.w * r.h
area2(Circle(2.0)) # 12.566370614359172
area2(Rectangle(2.0, 3.0)) # 6.0
area2("a circle") # MethodError — the error names the missing method
Note the failure mode: instead of an ArgumentError invented by the author, an unhandled type produces a MethodError that lists the signatures that were defined. That message is generated by the language, so it cannot drift out of date.
Why Dispatch Beats if
Dispatch is not merely tidier — it is faster and more extensible. The compiler specialises each method for the concrete types it receives, so the branch disappears entirely from the generated code. A type test inside a hot loop, by contrast, must be evaluated on every iteration and blocks that specialisation.
# Dispatch: the decision is made once, at compile time.
dims(a::AbstractVector) = (length(a),)
dims(a::AbstractMatrix) = size(a)
dims([1, 2, 3]) # (3,)
dims([1 2; 3 4]) # (2, 2)
# The same with a run-time test — slower and easy to get wrong.
function dims_bad(a)
ndims(a) == 1 ? (length(a),) : size(a)
end
# Dispatch is also open: a package can add a method without touching your code.
# That is how `plot(x)` works for a type that package has never seen.
Compact Forms
Beyond if and the ternary there are two more forms worth knowing: the ifelse function, which is not a substitute for the ternary because it evaluates both arguments, and the one-line if, which is accepted syntax when the body is short.
ifelse and Vectorised Choice
ifelse(cond, a, b) looks like the ternary, but as a function it must evaluate its arguments before it is called. The ternary evaluates only the chosen branch. That difference decides which one you may use: ifelse is right when both sides are cheap and safe, and the natural choice when you want to choose element-wise over an array.
# Both sides are evaluated, then one is returned.
ifelse(true, "yes", "no") # "yes"
# ❌ Unsafe with ifelse: the integer division runs even when the guard is false.
n = 0
ifelse(n == 0, 0, 1 ÷ n) # ERROR: DivideError — unlike the ternary
# ⚠ Note that 1 / n would NOT raise here: true division gives Inf, silently.
1 / 0 # Inf
# ✅ The ternary is lazy and therefore safe here.
n == 0 ? 0 : 1 ÷ n # 0
# ✅ ifelse shines when broadcast element-wise over a whole array.
temperatures = [-5.0, 4.0, -1.0, 12.0]
states = ifelse.(temperatures .< 0, :freezing, :liquid) # [:freezing, :liquid, :freezing, :liquid]
# The same choice with a ternary would need a comprehension:
states2 = [t < 0 ? :freezing : :liquid for t in temperatures]
One-Line and Compact Forms
Julia accepts ; as a statement separator, so a short branch can be written on one line. Use it for genuinely trivial bodies only — a one-line if that wraps a call and a second side effect is harder to read than the block form.
x = 5
# One-line if — accepted, and clear when the body is a single call.
if x > 0; println("positive"); end
# An if expression assigned in one line:
sign = if x > 0 "positive" else "non-positive" end
# Multiple statements need semicolons and stop being readable — use a block.
if x > 0; println("a"); println("b"); end # legal, but avoid
Common Pitfalls
Assignment Inside a Condition
Julia gives the classic C bug a distinct spelling: a single = inside a condition is a syntax error, because assignment is not allowed where a Bool is expected. Writing the comparison operator is therefore the only way to compile, which removes the "typo compiles and silently misbehaves" failure mode entirely.
x = 5
if x = 5 end # ERROR: syntax: unexpected "=" — assignment is not a value here
# The comparison is the only valid form:
if x == 5
println("five")
end
# If you genuinely need to assign and test, do it in two steps:
y = parse(Int, "7")
if y > 0
println(y)
end
A Missing else Returns nothing
An if without an else still produces a value when its condition is false — it produces nothing. That is easy to overlook when an if expression is assigned or used as a function's last expression, because the result is not an error but a silently empty value.
x = 3
result = if x > 10
"big"
end
result # nothing — the condition was false and there is no else
# The same trap inside a function body:
function label(x)
if x > 0
"positive"
end
end
label(-1) # nothing (a value of type Nothing), not an error
# Make the intent explicit:
function label2(x)
x > 0 ? "positive" : "non-positive"
end
label2(-1) # "non-positive"
Non-Boolean Conditions
Conditions that are not Bool raise a TypeError at the point of the test, which is far better than silently treating a value as true. The usual causes are a function that was expected to return a boolean but returned a collection, and an optional value that was never unwrapped.
# A predicate that returns a collection instead of a Bool:
contains(list, x) = filter(==(x), list) # returns a Vector, not a Bool
# ❌ TypeError: non-boolean (Vector{Int64}) used in boolean context
if contains([1, 2, 3], 2) end
# ✅ Ask the boolean question.
contains2(list, x) = any(==(x), list)
if contains2([1, 2, 3], 2)
println("found")
end
# The same for optional values: test for nothing first.
v = nothing
if v !== nothing && v > 0
println("positive")
end
Control flow in Julia is small, expression-based, and rarely the source of bugs — precisely because illegal conditions cannot compile and illegal types cannot pass a guard unnoticed. From here, the natural continuation is repetition: Loops & Iteration shows how the same expression-first design shapes for, while, and comprehensions.