Back to Blog

var vs make in Go: When to Use Each (with Examples)

var gives you the zero value; make initializes slices, maps, and channels. Here is exactly when to use each in Go, and why writing to a nil map panics but appending to a nil slice does not.

var vs make in Go: When to Use Each (with Examples)

Go gives you two ways to bring a variable into existence, var and make, and picking the wrong one is behind a whole class of early bugs: the nil map that panics on its first write, the channel that blocks forever, the slice you expected to be usable but was not. This post covers when each keyword applies, why slices, maps, and channels are the special cases, and how to choose correctly every time, with runnable code for each.

Table of Contents

What is the difference between var and make in Go?

var declares a variable and sets it to the type's zero value. It works for every type in the language. make is narrower: it accepts only a slice, map, or channel, the three built-in types that need runtime setup, and returns a value that is ready to hold data. The zero value of those three is nil, so var leaves them without the machinery make puts in place.

In practice that means make allocates the hash table behind a map, wires up the send and receive queue on a channel, and gives a slice a backing array to point at. Declare any of the three with var and you get nil instead.

Here is the same map two ways:

example.gogo
package main

import "fmt"

func main() {
	var counts map[string]int          // nil map, no hash table yet
	made := make(map[string]int)       // initialized, ready to write

	fmt.Println(counts == nil)         // true
	fmt.Println(made == nil)           // false

	made["/health"] = 1                // fine, made is initialized
	fmt.Println(made["/health"])       // 1

	// counts["/health"] = 1           // panic: assignment to entry in nil map
}

One detail surprises people: allocating memory usually means getting a pointer back, but make does not work that way. It allocates, then hands you the value directly.

So make([]int, 3) gives you a plain []int, not a *[]int. You use it as-is, with no * to dereference. Likewise make(map[string]int) gives you a map you can write to right away. The one built-in that does return a pointer is new, covered further down.

var x Tmake(T, ...)
Works forany typeslices, maps, channels only
Gives youthe zero valuean initialized, usable value
Slice resultnil slice (len 0, cap 0)non-nil slice with backing array
Map resultnil map (write panics)writable map
Channel resultnil channel (blocks forever)usable channel
Returnsthe valuethe value, never a pointer

When should you use var?

Use var for every type whose zero value is already useful: numbers, booleans, strings, structs, pointers, and interfaces. Go is designed so the zero value is a valid starting state, so you rarely need to initialize anything before using it.

A bytes.Buffer is the clearest example. It needs no constructor. Declare it with var and start writing:

example.gogo
package main

import (
	"bytes"
	"fmt"
)

func main() {
	var buf bytes.Buffer            // zero value is ready to use
	buf.WriteString("status=")
	buf.WriteString("ok")
	fmt.Println(buf.String())      // status=ok
}

The same holds for a sync.Mutex, whose zero value is an unlocked mutex ready to lock. There is no NewMutex constructor, and you never need one.

var also fits a slice you grow with append, and only that. A nil slice is a valid empty slice: length 0, capacity 0, and append allocates a backing array on the first element. What you cannot do is index into it: ids[0] = 3 panics with an index-out-of-range because the length is still 0. If you need to assign by index, reach for make([]int, n) to give the slice a real length up front. For the append-and-collect pattern, a nil slice works fine:

example.gogo
package main

import "fmt"

func main() {
	var ids []int                  // nil slice, but append-ready
	for i := 1; i <= 3; i++ {
		ids = append(ids, i*10)
	}
	fmt.Println(ids)               // [10 20 30]
	fmt.Println(ids == nil)        // false, append allocated it
}

Writing ids := make([]int, 0) here adds nothing. The nil slice reads more cleanly and behaves identically once the first append runs. The Composite Types course drills this slice-and-map behavior across a full set of exercises if you want the reps.

When should you use make?

Use make the moment you need a slice, map, or channel that is ready to hold data before you append to it. That covers three situations: a map you are about to write keys into, a channel goroutines will send on, and a slice you want pre-sized so its backing array is allocated up front.

Maps are the most common case, because a map has no append-style escape hatch. If you plan to assign any key, you must make it first. A request-counter in an HTTP handler is the textbook scenario:

example.gogo
package main

import "fmt"

func main() {
	hits := make(map[string]int)   // must initialize before writing

	for _, path := range []string{"/api", "/api", "/health"} {
		hits[path]++                // safe: map is initialized
	}
	fmt.Println(hits["/api"])      // 2
	fmt.Println(hits["/health"])   // 1
}

Channels are the same story. A channel you will send on has to be made, because a nil channel blocks forever (more on that below). A worker fan-out starts with a made channel:

example.gogo
package main

import "fmt"

func main() {
	jobs := make(chan int, 3)      // buffered channel, capacity 3
	jobs <- 1
	jobs <- 2
	close(jobs)

	for j := range jobs {
		fmt.Println("processing job", j)
	}
}

For slices, make has two shapes and the difference matters. make([]T, n) gives you a slice of length n filled with zero values, which you index into directly. make([]T, 0, n) gives you an empty slice with capacity n reserved, which you append into without triggering reallocations. Reach for the first when you know every slot will be filled by index, such as reading a fixed number of bytes:

example.gogo
package main

import "fmt"

func main() {
	// length 4, index into it directly
	buf := make([]byte, 4)
	copy(buf, []byte("data"))
	fmt.Println(string(buf))       // data

	// length 0, capacity 8, append without regrowing
	sums := make([]int, 0, 8)
	for i := 0; i < 8; i++ {
		sums = append(sums, i*i)
	}
	fmt.Println(sums)              // [0 1 4 9 16 25 36 49]
}

The second shape is a performance move: when you know roughly how many items you will append, pre-sizing the capacity avoids the repeated copy-to-a-bigger-array that append does as a slice grows. It changes nothing about correctness, only speed.

Why does writing to a nil map panic but appending to a nil slice work?

Because a nil slice and a nil map fail differently by design. append was built to handle a nil slice: on the first call it allocates a backing array and returns a real slice, so writing through append always works. A map has no equivalent. A write goes straight to a hash table that a nil map does not have, so the runtime panics rather than silently pretend.

This asymmetry trips up most people in their first week of Go. The next three examples walk through what each nil type actually does, starting with the nil slice, which reads and appends without complaint:

example.gogo
package main

import "fmt"

func main() {
	var s []int                    // nil slice
	fmt.Println(len(s), cap(s))    // 0 0
	fmt.Println(s == nil)          // true

	s = append(s, 42)              // works: append allocates
	fmt.Println(s)                 // [42]
}

Now the nil map. Reading from it is fine and returns the value type's zero value, and ranging over it does nothing, but the first write panics:

example.gogo
package main

import "fmt"

func main() {
	var m map[string]int           // nil map
	fmt.Println(m["missing"])      // 0, reading nil map is safe
	fmt.Println(len(m))            // 0, also safe

	m["key"] = 1                   // panic: assignment to entry in nil map
	fmt.Println(m)
}

The read-is-fine, write-panics split is what makes this so easy to miss. A nil map passes every test that only reads from it, then panics the first time real data flows through. The fix is always the same: initialize with make before the first write, or accept a map the caller already made.

Channels have their own nil behavior, and it is the strangest of the three: operations on a nil channel block forever rather than panic. A send or receive on a nil channel never proceeds, which would be a deadlock in a single goroutine:

example.gogo
package main

func main() {
	var ch chan int                // nil channel
	ch <- 1                        // blocks forever: fatal deadlock
}

This looks like a pure footgun, but it is actually a useful tool in a select. Setting a channel variable to nil disables its case, because a nil channel is never ready, which lets you turn a select branch off dynamically. One sharp edge to remember: close(nil) panics, so never close a channel you have not made. The Concurrency Fundamentals course covers the select-with-nil-channels pattern in depth. This nil-map and nil-slice pair also shows up in our roundup of common Go mistakes to avoid, because it catches experienced developers too.

What about new? (var vs make vs new)

new is the third allocation built-in, and it complements make rather than competing with it. new(T) allocates zeroed storage for a T and returns a *T, a pointer. Where make initializes a slice, map, or channel and returns the value, new works for any type and always returns a pointer to a zeroed one.

The classic use is getting a pointer to a fresh zero value without declaring a named variable first:

example.gogo
package main

import "fmt"

func main() {
	p := new(int)                  // *int pointing at a zeroed int
	fmt.Println(*p)                // 0
	*p = 7
	fmt.Println(*p)                // 7
}

Go 1.26 extends new so it can take an expression, not just a type. new(expr) allocates space, stores the value of that expression, and returns a pointer to it. This retires the old intPtr-style helper functions that existed only to point at a literal:

example.gogo
package main

import "fmt"

func main() {
	port := new(8080)              // *int pointing at 8080 (Go 1.26)
	enabled := new(true)           // *bool pointing at true

	fmt.Println(*port)             // 8080
	fmt.Println(*enabled)          // true
}

That is genuinely handy for optional struct fields, where a nil pointer means "unset" and any non-nil value is a deliberate choice. The Pointers & Memory course builds a full config-defaulting exercise around exactly this pattern. Here is how all three built-ins line up:

Built-inWorks forReturnsResult is usable?
var x Tany typethe zero valueusable if the zero value is (not for map writes)
make(T, ...)slice, map, channelthe value Tyes, initialized
new(T)any type*T, a pointerpoints at a zeroed T
new(expr) (Go 1.26)any expressionpointer to that valuepoints at the given value

Keep the focus simple: for a slice, map, or channel you are about to fill, make is almost always what you want. new earns its place when you specifically need a pointer, most often a pointer to a literal for an optional field.

A quick decision rule

You can collapse the whole topic into a short checklist. When you are about to declare something and are unsure which built-in to reach for, walk down this list and stop at the first line that matches:

  1. Map you will write keys into? Use make(map[K]V). A nil map panics on the first write.
  2. Channel goroutines will send on? Use make(chan T) or make(chan T, n). A nil channel blocks forever.
  3. Slice you want pre-sized for performance or index-assignment? Use make([]T, n) or make([]T, 0, n).
  4. Slice you will only grow with append? Use var s []T. The nil slice is append-ready.
  5. Need a pointer to a fresh value? Use new(T), or new(expr) on Go 1.26 to point at a literal.
  6. Anything else (int, string, bool, struct, most standard-library types)? Use var, because the zero value is ready.

The one habit that prevents the most bugs: before you write a key into any map, ask whether it was made. If a function receives a map as a parameter, the map came from the caller and is presumably initialized. If you declared it yourself with var, it is nil, and the first write will panic.

Frequently Asked Questions

Can I use var for a map?

You can declare a map with var m map[string]int, and reading from it, ranging over it, and calling len on it all work safely. What you cannot do is write a key: m["x"] = 1 panics with assignment to entry in nil map. If you only read, var is fine. If you will write any key, initialize it with make first, or take a map the caller already built.

Is make([]int, 0) the same as var s []int?

Behaviorally they act the same once you append, and both have length 0. The one observable difference is nil comparison: var s []int is nil, while make([]int, 0) is a non-nil empty slice. This only matters if your code or a test checks s == nil, or serializes to JSON where nil becomes null and the empty slice becomes []. For plain appending, prefer var.

Does make work for structs?

No. make only accepts slices, maps, and channels, and trying make(MyStruct) is a compile error. To create a struct, use a composite literal like MyStruct{}, declare it with var s MyStruct for the zero value, or use new(MyStruct) when you want a *MyStruct. A struct does not carry the hidden runtime structure that make exists to set up.

make vs new: which for a pointer?

Use new when you want a pointer. new(T) returns a *T pointing at a zeroed value, and on Go 1.26 new(expr) returns a pointer to that expression's value. make never returns a pointer: it returns an initialized slice, map, or channel value. So the two do not overlap. Reach for make to get a usable collection, new to get a pointer.

Sources

Primary references cited in this post (last verified July 3, 2026):

Ready to master Go?

Join LevelUpGo and start building real projects with interactive, hands-on lessons.

Start Learning Free