Loops & Control Flow#

So far our programs have run straight from top to bottom. Real programs need to make decisions (“if this customer is overdue, flag the invoice”) and repeat work (“for every row in the dataset, clean it up”). That’s what control flow is for.

Making decisions with if#

balance = 1200

if balance > 1000
    println("Premium account")
end

Add elseif to test more conditions, and else for the fallback:

function classify(balance)
    if balance > 10_000
        println("Status: VIP")
    elseif balance > 1000
        println("Status: Premium")
    else
        println("Status: Standard")
    end
end

classify(25_000)   # Status: VIP
classify(1200)     # Status: Premium
classify(50)       # Status: Standard

Three beginner notes that trip people up:

  • The condition does not need parentheses, and there’s no colon at the end of the line.

  • Indentation is for humans, not the compiler. Julia uses the end keyword to close blocks. Coming from Python this feels like extra typing; coming from anywhere else it feels normal. Either way, indent consistently — four spaces is the community convention.

  • It’s elseif, one word. Not elif, not else if.

Warning

Conditions must actually be Bool Julia will not accept a number where a boolean belongs:

julia> if 1
           println("nope")
       end
ERROR: TypeError: non-boolean (Int64) used in boolean context

No truthiness. 0 isn’t false, an empty array isn’t false. Write the comparison you actually mean: if count > 0, if !isempty(items). It’s stricter than Python, and it catches real bugs.

The ternary operator#

For picking one of two values, there’s a compact form:

a, b = 7, 12
smaller = a < b ? a : b       # 7

Read it as: “is a < b? then a, otherwise b.” The spaces around ? and : are required.

Short-circuit operators as statements#

This is idiomatic Julia and you’ll see it constantly in real code:

x > 0 || error("x must be positive")
isvalid(record) && process(record)

|| only evaluates its right side if the left is false; && only if the left is true. So the first line reads “either x is positive, or we throw an error” — a guard clause in one line. Perfectly readable once you’ve seen it a few times.

Repeating with for#

A for loop walks through the items of a collection, one at a time. This is the loop you’ll use most.

temps = [20.5, 22.3, 19.8, 25.1]

for temp in temps
    println("$temp °C")
end

Each time around, temp holds the next value. You can write instead of in if you’re feeling mathematical (\in + Tab); = also works and you’ll see it in older code.

Counting with ranges#

for i in 1:4
    println(i)        # prints 1, 2, 3, 4
end

1:4 is a range, and it’s worth knowing that it’s not a list — it’s a tiny object that stores its start, step, and stop, and generates values as needed. 1:1_000_000_000 costs almost no memory.

Ranges take a step in the middle:

for x in 9:-3:3           # start:step:stop
    println(x)            # 9, 6, 3
end

Note the ordering: start:step:stop, with the step in the middle. Python programmers reach for range(9, 0, -3) and get it wrong the first time. I certainly did.

Looping over data with its index#

Three patterns, each right in a different situation:

temps = [20.5, 22.3, 19.8, 25.1]

# 1. You only need the value
for t in temps
    println(t)
end

# 2. You need position and value — this is usually what you want
for (day, t) in enumerate(temps)
    println("Day $day$t °C")
end

# 3. You need the raw indices (e.g. to write back into the array)
for i in eachindex(temps)
    temps[i] = temps[i] * 1.8 + 32
end

Prefer eachindex(temps) over 1:length(temps). They’re identical for ordinary arrays, but eachindex also does the right thing for arrays with unusual index ranges, and it lets the compiler skip some bounds checks. It’s a free habit — pick it up now.

Nested loops, the Julia way#

Julia lets you flatten nested loops into a single for:

for i in 1:2, j in 1:3
    println("($i, $j)")
end

That’s exactly equivalent to two nested loops, just less indented. Note that break inside it exits both levels, since it’s really one loop.

Repeating with while#

Use while when you don’t know in advance how many times you’ll loop.

countdown = 5
while countdown > 0
    println(countdown)
    countdown -= 1
end
println("Liftoff!")

The danger with while is the infinite loop: if the condition never becomes false, your program runs forever. Here, countdown -= 1 is what guarantees we eventually stop. Always make sure something inside the loop moves you toward the exit. (And if you do hang the REPL, Ctrl-C is your friend.)

Steering loops: break and continue#

break exits the loop immediately:

invoices = [200, 450, -1, 700]

for amount in invoices
    if amount < 0
        println("Bad record found, stopping.")
        break
    end
    println("Processing invoice: $amount")
end

continue skips the rest of the current pass and jumps to the next item:

invoices = [200, 450, -1, 700]
total = 0

for amount in invoices
    amount < 0 && continue        # ignore bad records, keep going
    total += amount
end

println("Clean total: $total")    # 1350

break says “I’m done with this loop entirely.” continue says “skip this one, but keep looping.”

When things go wrong: try / catch#

Sometimes a line of code fails and you’d rather handle it than crash.

raw = ["1200", "800", "n/a", "1500"]
total = 0

for s in raw
    try
        total += parse(Int, s)
    catch e
        println("Skipping unparseable value: $s")
    end
end

println("Total: $total")     # 3500

Two words of caution, because this is easy to overuse.

First, try/catch in Julia has real runtime cost, and — more importantly — it blocks some compiler optimizations in the surrounding code. Don’t put it inside a hot loop that runs a million times. For the parsing case above, tryparse returns nothing on failure instead of throwing, and is much cheaper:

for s in raw
    v = tryparse(Int, s)
    v === nothing && continue
    global total += v
end

Second, catching everything hides bugs. If you only expect a parse failure, catch that specifically. finally is available too, for cleanup that must happen either way:

f = open("ledger.csv")
try
    process(f)
finally
    close(f)
end

Though for files specifically, the block form handles this for you:

open("ledger.csv") do f
    process(f)
end                        # closed automatically, even if process() throws

A small, real example#

Let’s tie it together. Imagine we’re scanning a week of account balances and want to count how many days fell below a threshold — the kind of quick check you’d do before feeding data to a model.

function count_low_days(balances, threshold)
    low_days = 0
    for (day, balance) in enumerate(balances)
        if balance < threshold
            low_days += 1
            println("Day $day is low: $balance")
        end
    end
    return low_days
end

balances = [1200, 800, 1500, 300, 950, 2000, 600]
n = count_low_days(balances, 1000)
println("Total low-balance days: $n")

Run it and you’ll see exactly which days fell short, plus a final count. That single pattern — loop over data, test a condition, accumulate a result — is the backbone of an enormous amount of data work.

Note

Why did I wrap that in a function? I could have written the loop at the top level of a script and it would have produced the same output. But code inside a function gets compiled and type-specialized; code at the top level of a script does not, because its variables are globals whose types could change at any moment.

This is the most consequential piece of Julia advice in the whole book, so I’m planting it here in chapter four rather than saving it for the performance chapter: put your real work inside functions. The same loop can be an order of magnitude faster purely by living inside a function. We’ll dig into exactly why in the Performance chapter.

Try it yourself#

  1. Print the numbers 1 through 10, but skip every multiple of 3.

  2. Given prices = [9.99, 19.50, 4.25, 30.00], compute and print the total using a loop.

  3. Starting from 100.0, repeatedly halve a number with a while loop, printing each step, until it drops below 1.

  4. Given readings = [20.5, -99.0, 22.3, -99.0, 19.8] where -99.0 means “sensor failed”, compute the average of only the valid readings.

Solutions#

# 1.
for n in 1:10
    n % 3 == 0 && continue
    println(n)
end

# 2.
function total_price(prices)
    total = 0.0
    for p in prices
        total += p
    end
    return total
end
println(total_price([9.99, 19.50, 4.25, 30.00]))

# 3.
value = 100.0
while value >= 1.0
    println(value)
    global value /= 2.0        # `global` needed only at the top level of a script
end

# 4.
function valid_mean(readings, sentinel = -99.0)
    total = 0.0
    count = 0
    for r in readings
        r == sentinel && continue
        total += r
        count += 1
    end
    return count == 0 ? NaN : total / count
end
println(valid_mean([20.5, -99.0, 22.3, -99.0, 19.8]))   # 20.866...

You can now branch and repeat, which means you can express real logic. Next we’ll package that logic into reusable functions — and in Julia, functions are considerably more interesting than they first appear.