Arrays as Values

Understand how Go arrays behave as value types, including copy-on-assignment, passing by value to functions, and using pointers to avoid unnecessary copying.

In Go, an array variable represents the entire array — not a pointer to its first element. This makes arrays fundamentally different from the reference-like behavior you see in languages like C, where an array name decays to a pointer. Concretely, when you assign an array to another variable or pass it to a function, the whole array is copied. The two variables then hold independent data.

This design removes a whole category of bugs where two pieces of code accidentally share and corrupt the same memory. It also means Go gives you full control: if you want sharing, you use a pointer. If you don’t, you get a clean, isolated copy by default.

Copy-on-Assignment Behavior

Every assignment of an array creates a complete, element-by-element copy. The original and the copy occupy separate memory; modifying one has no effect on the other.

package main
import "fmt"
func main() {
    original := [4]string{"Go", "Rust", "Python", "C"}
    copy := original
    copy[0] = "TypeScript"
    fmt.Println("original:", original)
    fmt.Println("copy:    ", copy)
}

Running this program prints:

original: [Go Rust Python C]
copy:     [TypeScript Rust Python C]

The copy variable starts as a duplicate of original. Changing the first element of copy leaves original untouched. This is the value semantics guarantee.

The copy operation works element by element. For an array of value types (like int or string), each element is copied by value. If the element type contains pointers, the pointer values are copied, not the data they point to — the same shallow/deep behavior you would expect from any struct assignment. But the array itself is duplicated.

Copy Cost:

For small arrays (say, up to a few dozen elements), the copy cost is negligible. For very large arrays — tens of thousands of elements or more — copying the whole block of memory every time you assign or pass the array can become a measurable overhead. In those cases, pass a pointer to the array instead.

Accidental Large Copies:

Newcomers sometimes define a large array and then pass it around by value without realizing each function call creates a full copy. If your array holds megabytes of data, this can cause performance problems. Use [1024]float64? Consider a pointer or a slice.

Passing Arrays by Value to Functions

When you pass an array to a function, the function receives a fresh copy. Any mutations inside the function are local; they do not affect the caller’s array.

package main
import "fmt"
func zeroOut(arr [5]int) {
    for i := range arr {
        arr[i] = 0
    }
    fmt.Println("inside zeroOut:", arr)
}
func main() {
    numbers := [5]int{10, 20, 30, 40, 50}
    fmt.Println("before:", numbers)
    zeroOut(numbers)
    fmt.Println("after: ", numbers)
}

The output shows that zeroOut successfully zeroes its own copy, but the original in main stays intact:

before: [10 20 30 40 50]
inside zeroOut: [0 0 0 0 0]
after:  [10 20 30 40 50]

This is a deliberate design choice. A function that receives an array by value cannot accidentally corrupt the caller’s data. It also means the caller doesn’t need to worry about defensive copying.

Functions Don’t Modify the Original Unless You Use a Pointer:

A common mistake is to assume the function will modify the caller’s array because that’s how arrays work in some other languages. In Go, pass-by-value means you must explicitly pass a pointer if you want in‑place changes. Without a pointer, the original is immutable from the function’s perspective.

For small arrays or cases where the function only reads the data, passing by value is perfectly fine. It keeps the code simple and eliminates aliasing surprises.

Passing a Pointer to an Array

To let a function modify the original array — or to avoid copying a large array — you pass a pointer to the array. The pointer itself is small (8 bytes on a 64‑bit system), and the function operates directly on the caller’s memory.

package main
import "fmt"
func fill(arr *[4]int, value int) {
    for i := range arr {
        arr[i] = value
    }
}
func main() {
    data := [4]int{1, 2, 3, 4}
    fill(&data, 99)
    fmt.Println(data) // [99 99 99 99]
}

Notice the function signature: arr *[4]int. The call site uses &data to get the pointer. Inside fill, arr[i] works without explicit dereference — Go automatically dereferences the pointer for you when you index into it.

Pointer to Array Is Efficient for Large Fixed-Size Data:

When you have a fixed-size array of moderate to large size (e.g., [256]byte, [64]float64), passing a pointer eliminates the per-call copy overhead while still letting you work with the array directly. This gives you slice-like efficiency with array-like compile‑time size guarantees.

There is a subtle difference between an array pointer and a slice, even though both allow in‑place modification. A pointer to [4]int can only point to an array of exactly length 4. The size is part of the type. If you need flexibility in length, you use a slice. But when the length is truly fixed and part of your data model (like a [3]float64 for RGB color or a [2]int for a coordinate), an array pointer preserves that invariant.

Comparing Arrays

Because arrays are values, you can compare two arrays with == and !=, provided their element type is comparable.

a := [3]int{1, 2, 3}
b := [3]int{1, 2, 3}
c := [3]int{4, 5, 6}
fmt.Println(a == b) // true
fmt.Println(a == c) // false

Two arrays are equal if they have the same length and each corresponding element is equal. This works for any element type that supports comparison — ints, strings, booleans, etc. Arrays whose element types are slices, maps, or functions cannot be compared with == (you’ll get a compile‑time error).

This comparison behavior is a natural consequence of value semantics. Since each array holds its own data, equality is straightforward: compare the data.

When to Choose Arrays as Values

The value-semantic nature of arrays is not a limitation; it’s a feature that suits certain use cases well:

  • Small, fixed-size data: A coordinate [2]int, an RGBA color [4]uint8, or a cryptographic nonce [12]byte. The copy is tiny, and value semantics prevent unintended sharing.
  • Constant-like data that needs to be mutable locally: You can pass an array by value, let the function mutate its copy, and return a result without worrying about side effects.
  • Embedded in structs for value types: If you want a struct to behave as a pure value (comparable, safe to copy), embedding an array inside it (rather than a slice) maintains that property.

If your collection needs to grow, or you frequently pass it around and want to avoid copying, a slice is almost always the better tool. Slices also give you value-like assignment of the slice header, but the underlying array is shared — a different set of trade‑offs.

Common Mistakes and Misconceptions

  • Expecting a function to modify the original array by passing it directly. This fails silently because the function works on its own copy. Always pass a pointer if mutation is intended.
  • Using large arrays as value parameters in hot paths. Every call copies the whole array. If an array is larger than a few cache lines, a pointer or a slice is measurably cheaper.
  • Confusing arrays with slices in assignment. An assignment b := a where a is a slice creates a new slice header that still points to the same backing array; mutations to b’s elements affect a. For arrays, assignment creates a full copy and there is no shared backing storage.
  • Trying to compare arrays of incomparable element types. [3][]int is not comparable and will not compile in an == expression. Use reflect.DeepEqual if you really need that comparison, but consider whether a different data structure would serve you better.

Pointer to Array ≠ Slice:

A pointer to an array (*[4]int) and a slice ([]int) both allow modification of the underlying data, but they are different types with different capabilities. A slice can grow with append and has dynamic length; an array pointer is fixed in size and cannot be used where a slice is expected without slicing it first. Choose based on whether the length is a compile‑time invariant or a runtime‑variable property.

Summary

Arrays in Go are values. An array variable owns its data, assignment and argument passing copy the entire array, and functions receive independent copies by default. This value semantics makes code safer, simplifies reasoning, and fits naturally with small, fixed‑size data. When you need shared mutation or want to avoid copying a large array, you reach for a pointer to the array.

Understanding this behavior is the foundation for the rest of Go’s collection types. Slices, which build on top of arrays, trade full‑copy semantics for a lightweight header that references a shared backing array. Grasping the difference now will make slices, length, capacity, and the append mechanism much easier to reason about.