---
url: /luau-basics/syntax-and-control-flow.md
description: >-
  Statements versus expressions, if-expressions, closures, and recursion in
  idiomatic Luau.
---

# Syntax and Control Flow

*Written: {{ $frontmatter.date }}*

## Learning objectives

* You tell statements apart from expressions.
* You use do blocks to limit scope.
* You pick if expressions over and or chains.
* You predict numeric for behavior.
* You write closures and recursion with base cases.
* You match each control form to its job.

## Prerequisites

* [Variables and Values](../programming-fundamentals/variables-and-values.md)
* [Operators and Logic](../programming-fundamentals/operators-and-logic.md)
* [Conditionals and Loops](../programming-fundamentals/conditionals-and-loops.md)
* [Functions and Scope](../programming-fundamentals/functions-and-scope.md)

## Statements do work, expressions produce values

Scripts mix actions that change state and values you can store.

Assignment is a statement. A call can be either. A compound assignment is a statement, never an expression. You cannot embed `+=` inside another expression. Read the compound assignment rules in the [syntax tour](https://luau.org/syntax).

Run this pure snippet in the Command Bar.

```luau
--!strict
local score = 10
score += 5
print(score)
```

```txt
15
```

You declare `score` with 10, add 5 with `+=`, then print 15. The middle line changes state and returns nothing. You cannot write `print(score += 5)` because `+=` produces no value.

Run this pure snippet in the Command Bar.

```luau
--!strict
local base = 4
local doubled = base * 2
print(doubled)
```

```txt
8
```

Here `base * 2` is an expression. It produces 8. Assignment stores that value. You use expressions where Luau expects a value. You use statements where Luau expects an action.

A function call with no return is a statement in practice. A call that returns values is an expression. You decide by checking whether you use the result.

## Do blocks limit scope

A do block groups statements and gives them a fresh scope. Locals declared inside end at `end`. You use do blocks to avoid name collisions and to release a temporary name fast.

Run this pure snippet in the Command Bar.

```luau
--!strict
local total = 1
do
	local total = 100
	print(total)
end
print(total)
```

```txt
100
1
```

The inner `total` shadows the outer one. The first print sees 100. After `end`, the inner name disappears. The second print sees 1. You use this pattern when you need a scratch variable with a common name.

Do blocks also help around a long calculation. Helper locals live inside, and only the final result stays outside. One-time work rarely needs a helper function. The control structure guide in [functions](https://create.roblox.com/docs/luau/functions) shows how blocks nest inside functions.

## If statements choose paths, if expressions choose values

Use an if statement when branches run different code. Pick the if expression when you choose between two values. The expression form looks like `if cond then a else b` with no `end`. The `else` branch is mandatory.

Run this pure snippet in the Command Bar.

```luau
--!strict
local level = 7
local label = if level >= 5 then "veteran" else "rookie"
print(label)
```

```txt
veteran
```

You read this as one value choice. The condition picks `"veteran"`. No `end` closes it. You can chain `elseif` inside the expression form too. Full syntax lives in the [syntax tour](https://luau.org/syntax).

Older Lua code often writes `cond and a or b` to fake a ternary. Do not copy that habit. It breaks when `a` is `false` or `nil`. The if expression always behaves correctly. You prefer it in every new script.

Run this pure snippet in the Command Bar.

```luau
--!strict
local enabled = false
local picked = if enabled then enabled else true
print(picked)
local tricky = enabled and enabled or true
print(tricky)
```

```txt
false
true
```

Both lines look similar but disagree. The if expression returns `false` because you asked for `enabled` itself. The and or chain returns `true` because `false or true` skips the falsy middle value. Only `nil` and `false` are falsy in Luau. Zero and empty strings are truthy. Stick with the if expression to avoid that bug.

Use an if statement when branches do different work.

Put this Script in ServerScriptService.

```luau
--!strict
local players = 3
if players <= 0 then
	print("lobby empty")
elseif players < 4 then
	print("waiting for more")
else
	print("match starts")
end
```

```txt
waiting for more
```

Each branch prints a different message and no value flows out, which signals a statement.

## Numeric for subtleties

A numeric for counts with a start, an inclusive limit, and an optional step. The step defaults to 1. The loop variable stays local to the loop. Luau evaluates the limit and step once at the start.

Run this pure snippet in the Command Bar.

```luau
--!strict
for i = 1, 3 do
	print(i)
end
```

```txt
1
2
3
```

The loop prints 1, 2, then 3. It includes the limit. This is 1-based thinking, the same habit you use for array indices.

Run this pure snippet in the Command Bar.

```luau
--!strict
for i = 5, 1, -1 do
	print(i)
end
```

```txt
5
4
3
2
1
```

A negative step counts down. You must supply it. Without it, `for i = 5, 1 do` runs zero times because the default step 1 can never reach 1 from 5.

Changing the limit variable inside the loop does not change the trip count. Luau freezes the limit and step before the first pass. Beginners sometimes bump a counter inside and expect more passes.

Run this pure snippet in the Command Bar.

```luau
--!strict
local limit = 3
for i = 1, limit do
	limit = 10
	print(i)
end
print(limit)
```

```txt
1
2
3
10
```

You set `limit` to 10 inside, but the loop still stops at 3. The final print proves the variable did change. The loop simply used the snapshot.

Float steps accumulate rounding error. Prefer integer counters when you need exact positions. Convert inside the body with division or multiplication. You keep loops deterministic and easy to test.

For arrays most days, a generalized for over the table reads better. Reach for numeric for when you need the index itself, a countdown, a stride, or a fixed repetition count. The iteration chapter [Advanced Functions](./functions-advanced.md) shows the generalized form side by side.

## Closures remember their surroundings

A closure is a function that captures locals from its enclosing scope. The function carries those variables with it after the outer function returns. Each call to the outer function creates fresh captures.

Run this pure snippet in the Command Bar.

```luau
--!strict
local function makeAdder(step: number)
	return function(value: number)
		return value + step
	end
end
local addFive = makeAdder(5)
print(addFive(10))
print(addFive(20))
```

```txt
15
25
```

`makeAdder` returns an anonymous function. That inner function remembers `step`. The first call adds 5 to 10. The second call adds the same 5 to 20. You use closures for counters, adders, filters, and callbacks that need memory. More shapes appear in [functions](https://create.roblox.com/docs/luau/functions) and in [Advanced Functions](./functions-advanced.md).

Loop capture trips up beginners. The loop variable is reused across passes in some languages, but each Luau pass gives the closure a value that stays correct when you copy it to a local first. Copy the value when you store closures in a loop. You avoid every closure sharing the final index.

## Recursion needs a base case

A recursive function calls itself on a smaller input and stops at a base case. Without one, you overflow the stack. State the base case first when you design the function.

Run this pure snippet in the Command Bar.

```luau
--!strict
local function factorial(n: number): number
	if n <= 1 then
		return 1
	end
	return n * factorial(n - 1)
end
print(factorial(5))
```

```txt
120
```

The base case is `n <= 1`. It returns 1 and ends the chain. Larger inputs multiply `n` by the result for `n - 1`. The call for 5 unfolds to `5 * 4 * 3 * 2 * 1`. Shrink the input on every call.

Prefer a loop when the work is a simple repetition or accumulation. Loops use constant stack space. Reserve recursion for naturally nested work such as tree walks, deep copies, and deep freezes. The deep patterns in [Tables in Depth](./tables-in-depth.md) use recursion for nested tables.

Keep recursion shallow in gameplay code. Deep nests can overflow on large player-built structures. Add a depth guard or convert to an explicit stack when input size is unbounded.

## When each control form fits

You now have five tools. Match the tool to the shape of the problem.

Use if statements for branching actions, if expressions for picking values, numeric for for counting, strides, and countdowns, generalized for for visiting table contents, while for open-ended conditions such as retries with a cap, closures when a function needs memory, and recursion when data nests.

Put this Script in ServerScriptService.

```luau
--!strict
local rolls = {4, 6, 2}
local total = 0
for _, value in rolls do
	total += value
end
local verdict = if total >= 10 then "strong" else "weak"
print(total)
print(verdict)
```

```txt
12
strong
```

Generalized for visits each roll, `+=` accumulates, and one if expression picks the verdict.

## Common mistakes

Wrong: you treat a compound assignment as a value.

```luau
--!strict
local x = 2
print(x += 3)
```

Studio reports a syntax error at `+=` because a statement sits where a value belongs. Fix it with two lines. Assign first, then print.

```luau
--!strict
local x = 2
x += 3
print(x)
```

```txt
5
```

Wrong: you fake a ternary with and or and lose `false`.

```luau
--!strict
local flag = false
local out = flag and flag or true
print(out)
```

The output is `true`, but you wanted the flag itself. Fix it with an if expression.

```luau
--!strict
local flag = false
local out = if flag then flag else true
print(out)
```

```txt
false
```

Wrong: you expect a loop to notice a changed limit.

```luau
--!strict
for i = 1, 2 do
	print(i)
end
```

You run this expecting extra passes after some edit inside. Luau keeps the original limit. Fix it by choosing the right bound up front or by switching to while when the bound genuinely moves.

Wrong: recursion without a base case.

```luau
--!strict
local function countdown(n: number): number
	return countdown(n - 1)
end
```

This never stops and overflows the stack. Fix it by handling the smallest input first and returning directly.

```luau
--!strict
local function countdown(n: number): number
	if n <= 0 then
		return 0
	end
	return countdown(n - 1)
end
print(countdown(3))
```

```txt
0
```

## Exercises

1. Pick values with if expressions. Choose `"adult"` when age reaches 18, else `"minor"`.
2. Count down from 10 by 2 with numeric for. Print each step.
3. Build a closure counter. Each call adds 1. Prove two counters stay independent.
4. Write factorial and Fibonacci recursively, each with an explicit base case. Compare recursion against a loop version and note when you prefer each.

::: details Solution 1
Run this pure snippet in the Command Bar.

```luau
--!strict
local age = 20
local group = if age >= 18 then "adult" else "minor"
print(group)
```

```txt
adult
```

You pick one of two strings with a single expression. No `end` closes it.
:::

::: details Solution 2
Run this pure snippet in the Command Bar.

```luau
--!strict
for i = 10, 1, -2 do
	print(i)
end
```

```txt
10
8
6
4
2
```

You count down with step negative 2. The limit 1 is inclusive, so the last printed value is 2.
:::

::: details Solution 3
Run this pure snippet in the Command Bar.

```luau
--!strict
local function makeCounter(): () -> number
	local count = 0
	return function()
		count += 1
		return count
	end
end
local a = makeCounter()
local b = makeCounter()
print(a())
print(a())
print(b())
```

```txt
1
2
1
```

Each maker call creates a fresh `count`. Calls to `a` do not move `b`. That independence proves the capture is per closure.
:::

::: details Solution 4
Run this pure snippet in the Command Bar.

```luau
--!strict
local function factorial(n: number): number
	if n <= 1 then
		return 1
	end
	return n * factorial(n - 1)
end
local function fib(n: number): number
	if n <= 1 then
		return n
	end
	return fib(n - 1) + fib(n - 2)
end
local total = 1
for i = 2, 5 do
	total *= i
end
print(factorial(5))
print(fib(6))
print(total)
```

```txt
120
8
120
```

Recursion fits naturally nested definitions. The loop fits plain accumulation because it uses constant stack space.
:::

## Summary

* Statements act. Expressions produce values. Compound assignments are statements.
* Do blocks scope temporaries. If expressions pick values. If statements branch actions.
* Numeric for snapshots its limit and step. Float steps risk drift.
* Closures carry memory. Recursion needs a base case and a smaller input.
* Next: [Tables in Depth](./tables-in-depth.md).
