Mutation, Copies & Memory#

Here is a bug I have watched more than one person write:

original = [1200.0, 800.0, 1500.0]
adjusted = original
adjusted[1] = 0.0

println(original)      # [0.0, 800.0, 1500.0]  — wait, what?

original changed. It changed because adjusted = original did not make a copy — it made a second name for the same array. Julia never silently copies a large object for you, and once you internalize that, this stops being surprising and starts being useful.

This chapter is about who owns what data, when it gets copied, and how to control that. It’s the closest thing Julia has to a memory model you need to hold in your head, and it’s short — but the errors it prevents are the annoying kind that only show up in production with real data.

Two kinds of value#

Every Julia value is either mutable or immutable, and this determines almost everything else.

ismutable([1, 2, 3])       # true  — Arrays
ismutable((1, 2, 3))       # false — Tuples
ismutable("hello")         # false — Strings
ismutable(42)              # false — Numbers

Immutable values — numbers, tuples, strings, and any plain struct — cannot be changed after creation. Because of that, Julia is free to copy them, store them inline inside arrays, or keep them in CPU registers. You can’t tell the difference, so it doesn’t matter which it does.

Mutable values — arrays, Dict, Set, and any mutable struct — live at a specific place in memory and are referred to by that location. Assignment copies the reference, not the contents.

That’s the whole model. adjusted = original copied a reference. Both names point at one array.

Identity vs. equality#

Julia gives you two comparisons and the distinction matters here:

a = [1, 2, 3]
b = [1, 2, 3]
c = a

a == b        # true  — same contents
a === b       # false — different objects
a === c       # true  — literally the same object

== asks “do these have the same value?” === asks “are these the same object?” When you’re debugging a mystery mutation, === is how you find out whether two variables are aliases.

Copying deliberately#

When you want independent data, say so:

original = [1200.0, 800.0, 1500.0]

shallow = copy(original)        # new array, same element objects
deep = deepcopy(original)       # new array, and recursively new elements

For an array of numbers, copy is all you need — the numbers are immutable, so sharing them is harmless.

The difference shows up with nested mutable data:

nested = [[1, 2], [3, 4]]

s = copy(nested)
s[1][1] = 99
nested[1][1]        # 99  — the inner arrays are still shared!

d = deepcopy(nested)
d[1][1] = 0
nested[1][1]        # 99  — untouched

copy gives you a new outer array holding the same inner arrays. deepcopy recursively copies everything.

Use copy by default. deepcopy is expensive and, in my experience, usually a sign that a data structure is more tangled than it should be.

Functions and mutation#

Julia passes arguments by reference to the value — sometimes called “pass by sharing.” A function receives the same object you passed. It cannot rebind your variable, but if the object is mutable, it can change its contents.

function zero_first!(xs)
    xs[1] = 0          # modifies the caller's array
    return xs
end

function rebind(xs)
    xs = [0, 0, 0]     # only rebinds the LOCAL name — caller unaffected
    return xs
end
julia> v = [1, 2, 3]
julia> rebind(v); v
[1, 2, 3]              # unchanged

julia> zero_first!(v); v
[0, 2, 3]              # changed

This is why the ! convention matters so much. There is no keyword in the signature telling you a function mutates — the name is the only signal, and the whole ecosystem honours it.

Follow it in your own code. If your function writes into an argument, put a ! on the name and put the mutated argument first. Future-you will be grateful.

Views: looking without copying#

We met this briefly in the data structures chapter. Slicing copies:

big = rand(1_000_000)
chunk = big[1:500_000]        # allocates 4 MB and copies

A view doesn’t:

chunk = @view big[1:500_000]  # allocates a few dozen bytes

A SubArray stores a reference to the parent plus the indices. Reading chunk[i] reads big[i]. Writing to it writes to big.

For a whole block of code, @views converts every slice:

@views function normalize_columns!(A)
    for j in axes(A, 2)
        col = A[:, j]              # a view, because of @views
        col .-= sum(col) / length(col)
    end
    return A
end

Without @views, that function allocates a fresh column array on every iteration and then throws away the modifications — a bug and a performance problem, fixed by one macro.

When to use views: passing sub-arrays into functions, iterating over rows or columns, any slicing inside a loop.

When not to: when you specifically want an independent copy, and when the view will be used repeatedly in a tight numerical kernel where the extra indirection can occasionally be slower than a contiguous copy. As always, measure.

Immutable structs and “modification”#

An immutable struct can’t be changed — so how do you change one?

struct Entry
    account::String
    amount::Float64
end

e = Entry("GL-4001", 1200.0)
e2 = Entry(e.account, 1500.0)          # build a new one

For structs with many fields that gets tedious, so Base gives you a shortcut:

e2 = Base.setproperty(e, :amount, 1500.0)     # or, more commonly:

using Accessors      # a small package, well worth it
e2 = @set e.amount = 1500.0

This “functional update” style — produce a new value rather than modifying in place — is worth getting comfortable with. It composes better, it’s safe to share across threads, and because the compiler knows immutable values can’t change, it often generates better code than the mutable equivalent.

Garbage collection#

Julia is garbage collected. You never free memory manually; when nothing references an object, it eventually gets reclaimed.

What you should know:

Allocation is the cost, not deallocation. Every time you create a heap object — an array, a growable string, a boxed value — you pay. Collecting later costs too, in pauses. This is why the performance chapter is largely about not allocating rather than about clever algorithms.

You can measure it directly:

julia> @time sum(rand(1000));
  0.000015 seconds (2 allocations: 7.938 KiB)

julia> @allocated sum(rand(1000))
8128

@time reports allocations right alongside the time, which is a small design decision with a big effect — it puts the number in front of you constantly, so you develop an instinct for it.

Julia’s GC is generational and, since 1.10, multithreaded. The practical upshot is that collection pauses on large heaps are much shorter than they used to be. If you last evaluated Julia’s GC several years ago, it’s improved considerably.

You can nudge it, though you rarely should. GC.gc() forces a collection. GC.enable(false) disables it temporarily, which is occasionally useful in a latency-critical section — and a great way to run out of memory if you forget to turn it back on.

Preallocation: the pattern that matters#

Here’s the single most valuable habit from this chapter. Instead of building a result incrementally:

function scale_slow(xs, factor)
    out = Float64[]
    for x in xs
        push!(out, x * factor)     # may reallocate as it grows
    end
    return out
end

Allocate once, up front:

function scale_fast(xs, factor)
    out = similar(xs, Float64)
    for i in eachindex(xs)
        out[i] = xs[i] * factor
    end
    return out
end

And if you’re calling it in a loop, hoist the allocation out entirely with a mutating version:

function scale!(out, xs, factor)
    for i in eachindex(xs, out)
        out[i] = xs[i] * factor
    end
    return out
end

# Reuse one buffer across a thousand iterations
buffer = similar(data, Float64)
for factor in factors
    scale!(buffer, data, factor)
    process(buffer)
end

That last pattern — a caller-supplied output buffer, reused — is how the fast numerical code in the ecosystem is written. It’s why so many Base functions come in both f and f! flavours.

Try it yourself#

  1. Create an array, assign it to a second variable, mutate through the second, and confirm the first changed. Then repeat with copy.

  2. Build a nested array [[1,2],[3,4]] and demonstrate the difference between copy and deepcopy.

  3. Write a function double!(xs) that doubles every element in place, and a non-mutating double(xs). Verify each behaves correctly.

  4. Compare @time and @allocated for sum(big[1:500_000]) versus sum(@view big[1:500_000]) on a million-element array.

Solutions#

# 3.
function double!(xs)
    for i in eachindex(xs)
        xs[i] *= 2
    end
    return xs
end

double(xs) = 2 .* xs        # broadcasting already returns a new array

# 4.
big = rand(1_000_000)
@allocated sum(big[1:500_000])         # ~4 MB
@allocated sum(@view big[1:500_000])   # ~0

Exercise 4 in particular: run it, look at the two numbers, and let that gap teach you the habit.

You now understand who owns what and when data gets copied. That’s most of what you need for the next chapter, which is where we put it all together and make Julia actually fast.