Variables & Constants

A Julia variable is a name bound to a value. The name itself has no fixed type: what it points to does. That single design decision — binding, not slot — explains why Julia code has no declarations, why a variable can be reassigned to a value of another type, and why 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, tolerancesconstNever change; documents intent
Configuration read once at startupconstFixes the type for every reader
Type names, module-level singletonsconstAliases get stable types
Loop counters, accumulatorsplain bindingThey change by design
Anything read inside a hot functionconstAvoids 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 holdsx::Int = 5Assert; error if it does not match
Change a value's typeconvert(Int, 3.0)Returns a new value of the target type
Construct the target typeFloat64(3)Same as convert for numbers
Truncate a float to an integertrunc(Int, 3.7)Explicit rounding, then conversion
Parse text into a numberparse(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 functionGoodType is known and fixed forever
Untyped global counter mutated in a loopBadForces dynamic lookup per access
const CACHE = Dict{String,Int}()AcceptableBinding is stable, type is concrete
Top-level script code with loopsWrap 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 sum or length breaks every later call to it in that scope.
  • Accidental reuse. Using i, x, or temp for 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.