Question
Creating 2D Slices in Go: Initialization, Layouts, and Patterns
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
dylabeled 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
[]uint8box of lengthdxin 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.
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:
-
dyis2anddxis3. -
make([][]uint8, dy)creates an outer slice of length 2. -
Its values are initially nil slices:
grid[0] == nil grid[1] == nil -
The first loop iteration has
y == 0.make([]uint8, dx)creates[0 0 0], which becomesgrid[0]. -
The second iteration has
y == 1. Another[0 0 0]row becomesgrid[1]. -
The grid can now be indexed safely:
grid == [][]uint8{{0, 0, 0}, {0, 0, 0}} -
grid[0][1] = 9changes 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.
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
| Representation | Example | Best for | Important property |
|---|---|---|---|
| Slice of slices | [][]uint8 | Rows that may vary in length; convenient grid[y][x] access | Inner rows are separate allocations in the usual setup |
| Flat slice | []uint8 | Dense rectangular grids, buffers, images | Access cell (x, y) with data[y*width+x] |
| Two-dimensional array | [H][W]uint8 | Compile-time fixed dimensions | Dimensions are part of the type; cannot resize |
| Nested literal | [][]uint8{{1, 2}, {3, 4}} | Small known data, tests, examples | Values 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.
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
0foruint8. - Check bounds:
0 <= y < len(grid)and0 <= x < len(grid[y]). - Use arrays like
[3][4]Tonly 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.
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.
Keep learning
Related questions
Are Go Maps Passed by Value or Reference?
Learn Go map parameter and return semantics: maps are passed by value, while copies share underlying data. Know when map pointers are useful.
Authentication for Go REST APIs: Sessions, Tokens, and Middleware
Learn practical authentication patterns for Go REST APIs, including password hashing, server sessions, bearer tokens, middleware, and security rules.
Automatic Build Versioning in Go: Embed Incrementing Build Numbers
Learn how to add automatic build versioning in Go using linker flags, build metadata, CI counters, and Git-based version values.