Variables & Constants
const exists for the cases where you must promise the binding will never change.
This lesson covers the four questions every language must answer about names: how a name gets a value, how the compiler knows its type without you saying so, how long the name lives and who can see it, and what it costs when the answer is "it lives in a global". The performance section at the end is the one that turns a working script into a fast one.
Bindings & Assignment
Assignment in Julia uses =, but it is not a declaration. It creates a binding: the name on the left is attached to the value on the right. No keyword, no type, no storage size appears, because the value already carries all of that information.
Assignment & Rebinding
Because a binding attaches a name to a value rather than reserving a typed slot, the same name may later be bound to a value of a completely different type. This is legal, and it is also the situation you should avoid in performance-critical code.
x = 42 # x is bound to an Int64
typeof(x) # Int64
x = "now a string" # rebinding: legal, and now x is a String
typeof(x) # String
x = 3.5 # rebinding again
typeof(x) # Float64
Julia also supports destructuring and chained assignment, which make several bindings at once. This is not sugar for readability alone — destructuring is how you unpack tuples and named tuples returned by functions:
a, b = 1, 2 # two bindings in one statement
a, b = b, a # swap without a temporary variable
println((a, b)) # (2, 1)
x = y = z = 0 # chained: all three names bound to 0
lo, hi = extrema([4, 1, 9]) # destructure a returned tuple
println((lo, hi)) # (1, 9)
Type Inference
If a name has no type, how does the compiler generate fast machine code? It infers the type from the value assigned and from how the name is used. Inference is the engine that makes untyped code fast, and it works forwards: from the literal, through every operation, to the result.
n = 10 # Int64 literal → n::Int64
m = n * 2 # inference knows the result is Int64
typeof(m) # Int64
s = n / 3 # `/` always produces a float, even for integer inputs
typeof(s) # Float64
println(s) # 3.3333333333333335
Most of the time inference succeeds completely and you never think about it. It fails when a name can hold different types in different branches or across loop iterations, because then the compiler must allow the general case and generate slower code that boxes values. That situation has a name — type instability — and a tool to detect it, covered in the globals section below and in depth in Performance & Benchmarking.
One practical consequence of inference: writing the most natural expression, not the most annotated one, is usually the fastest path. Julia rewards idiomatic code, not defensive typing.
Constants
Julia's const binds a name to a value so that the binding cannot be reassigned. It is the closest thing the language has to a declaration, and it exists for two distinct reasons: correctness and speed.
The const Keyword
Reassigning a const is an error in a new session but only a warning at the REPL in older versions — so rely on convention as well as the error. The value itself may still be mutable; const freezes the binding, not the object.
const MAX_ITER = 1000
const G = 9.80665 # standard gravity, m/s²
const NAMES = ["alpha", "beta"] # the binding is constant; the array is not
MAX_ITER = 2000 # ❌ ERROR: invalid redefinition of constant
push!(NAMES, "gamma") # ✅ allowed — mutating the object, not rebinding
println(NAMES) # ["alpha", "beta", "gamma"]
That last pair is the distinction people miss. NAMES will always refer to the same array, so the compiler may assume its type is Vector{String} forever. The array's contents are free to change, and every reference to NAMES sees the change immediately.
Constants by Convention
There is a second, quieter reason to declare constants: the compiler can treat a global name as a constant only if you said so. A global that is never reassigned may still be inferred as Any unless it is declared const. The table below summarises when to reach for it.
| Situation | Use | Why |
|---|---|---|
| Physical constants, tolerances | const | Never change; documents intent |
| Configuration read once at startup | const | Fixes the type for every reader |
| Type names, module-level singletons | const | Aliases get stable types |
| Loop counters, accumulators | plain binding | They change by design |
| Anything read inside a hot function | const | Avoids Any-typed globals |
Scope & Lifetime
Scope answers two questions: which names can a given line see, and how long does a value survive. Julia's scope rules are slightly unusual because functions behave differently from the REPL and scripts, and that difference is the source of the famous UndefVarError inside a loop typed at the top level.
Local vs Global Scope
Names created inside a function, loop, let block, or comprehension are local: they exist only for the duration of that block and are invisible outside it. Names created at the top level of a module, script, or the REPL are global for that container.
function stats(v)
total = sum(v) # `total` is local to this function
n = length(v)
return total / n
end
println(stats([1, 2, 3])) # 2.0
# println(total) # ❌ UndefVarError — it never existed out here
let # `let` creates a local scope at the top level
inside = 99
println(inside) # 99
end
# println(inside) # ❌ UndefVarError
A function may read a global but may not assign to it without saying so. The global keyword grants permission, and local forces a new local instead — the two halves of an explicit scope declaration:
counter = 0
function bump()
global counter # without this line, Julia raises an error
counter += 1
end
bump(); bump()
println(counter) # 2
Using global to accumulate state is worth recognising as a smell. In a real program, passing the value in and returning the new value is both clearer and dramatically faster — the reason is the performance section below.
Soft vs Hard Scope at the Top Level
Julia distinguishes hard scopes, which always introduce a new local variable, from soft scopes, which inherit the enclosing scope when one exists. Functions, let blocks, and struct bodies are hard; for and while loops introduced at the interactive prompt or in a script file are soft.
# Typed at the REPL (global soft scope) — this assigns the global `i`.
i = 0
for i in 1:3
println(i)
end
println(i) # 3 — the global was updated
# Inside a function (hard scope) — this creates a new local `i`.
function f()
i = 0
for i in 1:3
# `i` here is local to the loop body
end
return i # still 0 — the loop's `i` never escaped
end
println(f()) # 0
Type Annotations
You never have to annotate a variable. Annotations exist to express intent, to constrain what a name may hold, and occasionally to help the compiler — in that order of importance. Used well they document a contract; used badly they fight inference and slow code down.
Annotating a Binding
Two operators appear here, and they are not interchangeable. The double colon :: is a type assertion — it declares what a name or expression holds. The function convert and its constructor form actually change the value's type.
# `x::T = value` asserts the binding; the value must already conform.
y::Float64 = 3.0 # fine
# z::Float64 = 3 # ❌ InexactError: the literal 3 is an Int64
# Assertion on a name, useful to document a field or a local:
n::Int = 7
typeof(n) # Int64
# Asserting an expression tells the compiler what to expect:
v = Any[1, 2, 3]
first(v)::Int # asserts the pulled-out value is an Int
Assertions are checked at runtime where the compiler cannot prove them, so a wrong assertion is a caught bug, not silent corruption. In a struct field, the annotation defines the storage layout and is compulsory if you want the type to be concrete.
Conversion vs Annotation
When you have a value of one type and want another, assert is the wrong tool — convert is. The distinction matters constantly in numeric code, where a floating-point result must be stored in an integer field or vice versa.
| Intent | Write | Behaviour |
|---|---|---|
| Declare what a name holds | x::Int = 5 | Assert; error if it does not match |
| Change a value's type | convert(Int, 3.0) | Returns a new value of the target type |
| Construct the target type | Float64(3) | Same as convert for numbers |
| Truncate a float to an integer | trunc(Int, 3.7) | Explicit rounding, then conversion |
| Parse text into a number | parse(Int, "42") | Throws on malformed input |
convert(Float64, 3) # 3.0
convert(Int, 3.0) # 3 — exact, so allowed
# convert(Int, 3.7) # ❌ InexactError: 3.7 is not exactly representable
trunc(Int, 3.7) # 3 — you said how to round
round(Int, 3.7) # 4
parse(Int, "42") # 42
parse(Float64, "2.5e-3") # 0.0025
Globals & Performance
This section is the reason the lesson includes a performance chapter at all. Wrapping code in a function can make the same algorithm tens or hundreds of times faster purely by changing where the variables live — with no other edit. Understanding why removes a whole class of "Julia is slow" complaints.
Why Typed Globals Matter
A global binding may be reassigned at any moment, from anywhere, to a value of any type. So when a function reads a global, the compiler cannot know its type at the time it generates code. The generated code must therefore box the value, look up its type dynamically, and dispatch at runtime for every access — inside the loop, on every iteration.
# ❌ Global, untyped → the loop body cannot be compiled to fast code.
sum_val = 0.0
function bad(n)
for i in 1:n
global sum_val
sum_val += i
end
return sum_val
end
# ✅ The value is passed in; the parameter has a concrete inferred type.
function good(n)
total = 0.0 # local, inferred as Float64
for i in 1:n
total += i # compiled to a tight native loop
end
return total
end
Measured, the difference is not marginal. The idiom to internalise is that data flows in through arguments and out through return values; globals are for constants and configuration, never for accumulators.
Passing State Instead
When a value truly is constant for the life of the session, a typed const gives the compiler what it needs while keeping the convenience of a global name. When the value changes, it should be a parameter or a field — not a global that every function reaches out to touch.
| Pattern | Verdict | Reason |
|---|---|---|
const RATE = 0.07 read in a function | Good | Type is known and fixed forever |
| Untyped global counter mutated in a loop | Bad | Forces dynamic lookup per access |
const CACHE = Dict{String,Int}() | Acceptable | Binding is stable, type is concrete |
| Top-level script code with loops | Wrap in main() | Function scope restores inference |
# A concrete container behind a const binding: fast and reusable.
const CACHE = Dict{String, Int}()
function memo_fib(n)
key = string(n)
haskey(CACHE, key) && return CACHE[key] # concrete key and value types
val = n < 2 ? n : memo_fib(n - 1) + memo_fib(n - 2)
CACHE[key] = val
return val
end
println(memo_fib(30)) # 832040
Common Pitfalls
UndefVarError & UndefRefError
Two errors look similar and mean different things. UndefVarError means the name does not exist in this scope — usually a typo, a case mismatch, or a variable that was defined inside a function that already returned. UndefRefError means the name exists but has no value yet, which happens with array slots and struct fields that were declared but never filled.
# UndefVarError — the name was never created here
function g()
result = 1
end
g()
# println(result) # ❌ UndefVarError: `result` not defined
# UndefRefError — the slot exists but was never assigned
v = Vector{String}(undef, 3) # three uninitialised slots
# println(v[1]) # ❌ UndefRefError
v[1] = "ok"
println(v[1]) # ok
The lesson from the first case: a function's locals die with the call. If you need a value to survive, return it. The lesson from the second: Vector{T}(undef, n) allocates storage but writes nothing, so read position i only after you have written position i.
Shadowing & Reuse
Rebinding a name to a different type is legal, but doing it routinely is a hazard: it defeats inference inside functions that use the name, and it makes a reader track two meanings for one word. The three symptoms below are worth recognising by name.
- Shadowing. A local variable with the same name as a function, type, or global hides it for the rest of the block. Naming a variable
sumorlengthbreaks every later call to it in that scope. - Accidental reuse. Using
i,x, ortempfor two unrelated purposes in one function. Each new meaning resets the inferred type. - Function name capture. Assigning to a name in a scope where a function of that name exists makes the function unreachable there.
function demo(v)
total = sum(v) # ✅ `sum` is the function — fine
# sum = 0 # ❌ would shadow `sum` and break the line above
return total
end
println(demo([1, 2, 3])) # 6
The habit that avoids all three: choose names that say what the value is — total, row_count, elapsed_ms — and never reuse a name for a second purpose inside one block. It costs nothing and depends on no tooling.
You now know how names are bound, how inference types them, how const freezes a binding, how scope decides visibility, what annotations actually assert, and why globals cost performance. Next: Data Types maps the type tree those values live in.