Question

Creating 2D Slices in Go: Initialization, Layouts, and Patterns

gomatrixmultidimensional-arrayslicejagged-arrays

Question

I am learning Go with A Tour of Go. An exercise asks me to create a two-dimensional slice with dy rows and dx columns, where every element is a uint8.

This code works:

a := make([][]uint8, dy) // Create dy row slots.
for i := 0; i < dy; i++ {
    a[i] = make([]uint8, dx) // Create dx uint8 values for each row.
}

Is there a more concise or idiomatic way to initialize a 2D slice in Go? How should the approach change for higher-dimensional slices?

Short Answer

By the end of this page, you will understand why a Go [][]uint8 requires initialization for both its outer and inner slices, how to write that initialization idiomatically, and when a flat one-dimensional slice is a better representation for grid-like data.

Concept

A two-dimensional slice in Go is usually represented as a slice of slices:

[][]uint8

The outer slice contains row slices. Each row is itself a separate []uint8 slice.

rows := make([][]uint8, 3)

At this point, rows has three entries, but each entry is a nil slice:

fmt.Println(rows)
// [[] [] []]

You cannot write rows[0][0] yet because rows[0] has no backing array and has length zero. Each row must be allocated before it can hold uint8 values.

for i := range rows {
    rows[i] = make([]uint8, 4)
}

This is idiomatic Go. The loop is not unnecessary boilerplate: it creates each independent inner slice.

A slice is a small descriptor containing a pointer, length, and capacity. Therefore, make([][]uint8, dy) allocates descriptors for dy rows, not dy * dx uint8 elements. The second allocation step creates the elements for each row.

This structure is useful because rows may have different lengths. Such a structure is called a jagged or ragged slice.

triangle := [][]int{
    {1},
    {2, 3},
    {4, 5, 6},
}

For a rectangular grid where every row has the same width, a flat []uint8 is often another good option. It has one allocation and stores all values together.

Mental Model

Think of [][]uint8 as a bookshelf:

  • The outer slice is the bookshelf with dy labeled spaces for rows.
  • Each row slice is a separate box placed into one of those spaces.
  • make([][]uint8, dy) builds the bookshelf, but the spaces initially contain no boxes.
  • The loop creates and places one []uint8 box of length dx in every space.

A flat []uint8 is different: it is one large box containing all cells in row order. To find a cell, you calculate its position yourself with y * width + x.

Take Quiz

Syntax and Examples

Create a rectangular 2D slice with an outer allocation followed by one allocation per row:

package main

import "fmt"

func main() {
    dy, dx := 3, 4

    pixels := make([][]uint8, dy)
    for y := range pixels {
        pixels[y] = make([]uint8, dx)
    }

    pixels[1][2] = 255
    fmt.Println(pixels)
}

range pixels produces the valid indexes of the outer slice. It is often clearer than manually writing i := 0; i < dy; i++ because it stays correct if the slice length changes.

The output is conceptually:

[[0 0 0 0] [0 0 255 0] [0 0 0 0]]

For a fixed, known-at-compile-time size, an array is another option:

var grid [3][4]uint8
grid[1][2] = 255

Arrays include their length in their type, so [3][4]uint8 cannot be resized and is not interchangeable with [][]uint8.

For a literal with known initial values, use nested composite literals:

board := [][]uint8{
    {1, 2, 3},
    {4, 5, 6},
}

Step by Step Execution

Consider this code:

dy, dx := 2, 3

grid := make([][]uint8, dy)
for y := range grid {
    grid[y] = make([]uint8, dx)
}

grid[0][1] = 9

Execution trace:

  1. dy is 2 and dx is 3.

  2. make([][]uint8, dy) creates an outer slice of length 2.

  3. Its values are initially nil slices:

    grid[0] == nil
    grid[1] == nil
    
  4. The first loop iteration has y == 0. make([]uint8, dx) creates [0 0 0], which becomes grid[0].

  5. The second iteration has y == 1. Another [0 0 0] row becomes grid[1].

  6. The grid can now be indexed safely:

    grid == [][]uint8{{0, 0, 0}, {0, 0, 0}}
    
  7. grid[0][1] = 9 changes the second cell of the first row:

    grid == [][]uint8{{0, 9, 0}, {0, 0, 0}}
    

The zero values come from make: every new uint8 element starts at 0.

Real World Use Cases

Two-dimensional data appears in many programs:

  • Images: each row contains pixel brightness values, palette indexes, or color components.
  • Game boards: cells for chess, Sudoku, Minesweeper, tile maps, or cellular automata.
  • Spreadsheets and tables: rows and columns of values.
  • Dynamic programming: a table stores intermediate results for algorithms such as edit distance.
  • API and file parsing: records may be naturally grouped into rows with potentially different field counts.

Choose [][]T when rows are meaningful separate collections or may differ in length. Choose a flat []T when you need a dense rectangular grid and want a simple contiguous representation.

Take Quiz

Real Codebase Usage

In production Go code, developers commonly hide multidimensional allocation behind a constructor function. This makes the intended dimensions explicit and keeps allocation details in one place.

func NewGrid(height, width int) [][]uint8 {
    grid := make([][]uint8, height)
    for y := range grid {
        grid[y] = make([]uint8, width)
    }
    return grid
}

Validate dimensions at boundaries when they come from users, requests, files, or configuration:

func NewGrid(height, width int) ([][]uint8, error) {
    if height < 0 || width < 0 {
        return nil, fmt.Errorf("grid dimensions cannot be negative")
    }

    grid := make([][]uint8, height)
    for y := range grid {
        grid[y] = make([]uint8, width)
    }
    return grid, nil
}

For performance-sensitive rectangular data, codebases often use a flat buffer and an accessor method:

type Grid struct {
    Width  int
    Height int
    Data   []uint8
}

func (g Grid) At(x, y int) uint8 {
    return g.Data[y*g.Width+x]
}

func (g Grid) Set(x, y int, value uint8) {
    g.Data[y*g.Width+x] = value
}

This design centralizes index calculation so callers use At and Set rather than repeating y*width+x throughout the codebase.

Common Mistakes

Allocating only the outer slice

This compiles but panics when indexing an uninitialized row:

grid := make([][]uint8, 2)
grid[0][0] = 1 // panic: index out of range

Fix it by allocating every inner row before writing to it.

for y := range grid {
    grid[y] = make([]uint8, 3)
}

Reusing the same row accidentally

Do not create one row and assign it to every outer index:

row := make([]uint8, 3)
grid := make([][]uint8, 2)

for y := range grid {
    grid[y] = row
}

grid[0][0] = 9
fmt.Println(grid) // [[9 0 0] [9 0 0]]

Both entries point to the same underlying row. Allocate a new row during each iteration instead.

Assuming rows must have equal lengths

Go allows different row lengths:

grid := [][]int{
    {1, 2},
    {3, 4, 5},
}

If your algorithm requires a rectangle, ensure every row has the expected width before indexing it.

Confusing arrays and slices

This array has sizes that are part of its type:

var a [2][3]uint8

This is a slice of slices whose lengths can be decided at runtime:

a := make([][]uint8, 2)

Using append when a known size is available

Repeatedly appending rows works, but allocating known dimensions directly is clearer and avoids capacity-growth concerns:

// Prefer make when dy and dx are already known.
grid := make([][]uint8, dy)
for y := range grid {
    grid[y] = make([]uint8, dx)
}

Comparisons

RepresentationExampleBest forImportant property
Slice of slices[][]uint8Rows that may vary in length; convenient grid[y][x] accessInner rows are separate allocations in the usual setup
Flat slice[]uint8Dense rectangular grids, buffers, imagesAccess cell (x, y) with data[y*width+x]
Two-dimensional array[H][W]uint8Compile-time fixed dimensionsDimensions are part of the type; cannot resize
Nested literal[][]uint8{{1, 2}, {3, 4}}Small known data, tests, examplesValues are supplied immediately

A [][]uint8 is not automatically one contiguous rectangle. Each row may have its own backing array, length, and capacity. A flat slice is one sequence of height * width values.

For three or more dimensions, nested slices continue the same pattern:

volume := make([][][]uint8, depth)
for z := range volume {
    volume[z] = make([][]uint8, height)
    for y := range volume[z] {
        volume[z][y] = make([]uint8, width)
    }
}

There is no built-in make form that automatically allocates all nested slice levels. For repeated use, write a helper function or consider flattening the data.

Take Quiz

Cheat Sheet

// Create a dy-by-dx slice of uint8 values.
grid := make([][]uint8, dy)
for y := range grid {
    grid[y] = make([]uint8, dx)
}

// Read and write.
grid[y][x] = 255
value := grid[y][x]

// A small known literal.
grid := [][]uint8{
    {1, 2},
    {3, 4},
}

// Flat rectangular alternative.
data := make([]uint8, width*height)
index := y*width + x
data[index] = 255
  • make([][]T, rows) creates outer entries, not inner elements.
  • A new make([]T, cols) in each iteration gives each row independent storage.
  • New numeric elements have their zero value, such as 0 for uint8.
  • Check bounds: 0 <= y < len(grid) and 0 <= x < len(grid[y]).
  • Use arrays like [3][4]T only when dimensions are fixed at compile time.
  • Use a flat slice when contiguous rectangular storage is more suitable.

FAQ

Is there a one-line built-in way to make a 2D slice in Go?

No built-in make call allocates all nested slice rows at once. The idiomatic approach is to allocate the outer slice and use a loop to allocate each inner slice.

Why does make([][]uint8, dy) not create dx columns too?

Its type is a slice of []uint8 values. It knows how many row slots to create, but it has no column length argument and does not allocate the backing arrays for those rows.

Is the initialization loop inefficient?

No. It performs the allocations needed for independent rows. For a dense rectangle where allocation layout matters, a single flat allocation may be preferable.

Can each row of a Go 2D slice have a different length?

Yes. [][]T supports ragged data naturally because each inner slice has its own length.

How do I make a contiguous 2D grid in Go?

Use one flat slice, such as make([]uint8, width*height), and convert coordinates with index := y*width + x.

Should I use for i := 0; i < dy; i++ or for i := range grid?

Both are correct. for i := range grid is often more idiomatic when you are iterating over every index of an existing slice.

What happens if I access an uninitialized inner slice?

Reading or writing an element such as grid[0][0] panics because the inner slice has length zero until it is allocated.

Take Quiz

Mini Project

Description

Build a small grayscale image grid. Each cell stores a uint8 brightness value from 0 (black) to 255 (white). The project demonstrates allocating a rectangular [][]uint8, filling it with a pattern, and reading it row by row.

Goal

Create and print an 8-by-4 grayscale grid whose brightness increases from left to right.

Requirements

Create a two-dimensional [][]uint8 grid with 4 rows and 8 columns. Allocate every row before assigning cell values. Fill each row with brightness values from 0 through 255. Print the completed grid one row at a time.

Take Quiz

Keep learning

Related questions

View more