The new and make Functions

Go has two built-in functions for allocating memory: new and make. They look similar — both hand you freshly allocated memory — but they solve different problems and are not interchangeable. Mixing them up is one of the most common sources of confusion for programmers new to Go, especially the surprise of a program that compiles perfectly but panics at runtime with assignment to entry in nil map. This lesson explains exactly what each function does under the hood, when to reach for which one, and the mistakes that trip people up.

Overview / How it works

new(T) is the general-purpose one. It works for any type T: a basic type like int, a struct, an array, even a pointer type. It allocates enough memory to hold a value of type T, zeroes that memory (sets it to T‘s zero value — 0 for numbers, "" for strings, nil for pointers/slices/maps, a struct with all fields zeroed, and so on), and returns a pointer of type *T pointing at that memory. That’s it. new never initializes anything beyond zeroing — it just gives you a safe, addressable place to put a value.

make(T, ...) is specialized. It only works on three types: slices, maps, and channels. Unlike new, it does not return a pointer — it returns an initialized value of type T itself, ready to use immediately. The reason make exists as a separate function is that slices, maps, and channels are not simple flat values; each one wraps an internal data structure that needs real setup work before it’s usable, not just zeroed bytes.

Consider what the zero value of each of these types actually is. A nil slice has no backing array. A nil map has no hash table. A nil channel has no internal buffer or synchronization structure. If you called new([]int), you would get a *[]int — a pointer to a slice header that is still nil. Dereferencing it gives you a perfectly usable empty slice you can append to (slices tolerate a nil starting point), but a new(map[string]int) gives you a pointer to a nil map, and nil maps panic the moment you try to write to them. make exists precisely to skip past this problem: make(map[string]int) allocates the internal hash table (Go’s hmap structure) up front, so the map is genuinely ready for writes the instant you get it back. Likewise make([]int, 5) allocates a real backing array of 5 elements and returns a slice header (pointer, length, capacity) pointing at it, and make(chan int, 3) allocates the channel’s internal ring buffer and the synchronization primitives goroutines use to send and receive safely.

Because make‘s job is so specific, the compiler enforces it strictly: you cannot call make on a struct, an int, or any type other than a slice, map, or channel — that’s a compile-time error. And you technically can call new on a slice, map, or channel type, but you almost never should, because the pointer it gives you back still refers to an uninitialized (nil) value.

Syntax

The general forms look like this:

new(Type)                              // returns *Type, a pointer to a zeroed Type
make([]Type, length)                   // returns []Type with len == cap == length
make([]Type, length, capacity)         // returns []Type with len == length, cap == capacity
make(map[KeyType]ValueType)            // returns an initialized, empty map
make(map[KeyType]ValueType, sizeHint)  // returns a map pre-sized for sizeHint entries
make(chan Type)                        // returns an unbuffered channel
make(chan Type, bufferSize)            // returns a channel buffered for bufferSize values
  • new(Type) — takes exactly one argument, a type. Returns a pointer.
  • make(Type, …) — takes a type plus one or two extra size arguments (their meaning depends on whether Type is a slice, map, or channel). Returns a value, not a pointer.
  • For slices, the second argument is length, the optional third is capacity (capacity must be >= length).
  • For maps, the optional second argument is only a capacity hint for the hash table — the map still grows automatically beyond it.
  • For channels, the optional second argument is the buffer size; omitting it creates an unbuffered (synchronous) channel.

Examples

Example 1: new() with a basic type and a struct

package main

import "fmt"

type Point struct {
	X int
	Y int
}

func main() {
	n := new(int)
	fmt.Println(*n)
	*n = 42
	fmt.Println(*n)

	p := new(Point)
	fmt.Println(*p)
	p.X = 10
	p.Y = 20
	fmt.Println(*p)
}

Output:

0
42
{0 0}
{10 20}

new(int) allocates a single zeroed int and returns its address; dereferencing with *n reads or writes through that pointer. new(Point) works the same way for a struct — every field starts at its own zero value. Notice that p.X = 10 works directly on the pointer p without an explicit dereference; Go automatically dereferences pointers to structs when you access fields, so p.X is shorthand for (*p).X.

Example 2: make() with a slice, a map, and a channel

package main

import "fmt"

func main() {
	s := make([]int, 3, 5)
	fmt.Println(s, len(s), cap(s))

	m := make(map[string]int)
	m["a"] = 1
	m["b"] = 2
	fmt.Println(m)

	ch := make(chan int, 2)
	ch <- 10
	ch <- 20
	fmt.Println(<-ch, <-ch)
}

Output:

[0 0 0] 3 5
map[a:1 b:2]
10 20

make([]int, 3, 5) creates a slice with length 3 (three zeroed elements you can already index) but capacity 5 (room to grow to 5 elements via append before a reallocation is needed). make(map[string]int) hands back a map that’s immediately safe to write to — note that fmt prints map entries sorted by key, which is why b appears after a even though insertion order doesn’t guarantee that. make(chan int, 2) creates a channel with a 2-slot buffer, so both sends succeed without a receiver waiting on the other end; the two receives then drain it in order.

Example 3: a realistic mix — constructor pattern plus a pre-sized slice

package main

import "fmt"

type Counter struct {
	value int
}

func (c *Counter) Increment() {
	c.value++
}

func NewCounter() *Counter {
	return new(Counter)
}

func main() {
	c := NewCounter()
	c.Increment()
	c.Increment()
	c.Increment()
	fmt.Println(c.value)

	results := make([]int, 0, 3)
	for i := 1; i <= 3; i++ {
		results = append(results, i*i)
	}
	fmt.Println(results, len(results), cap(results))
}

Output:

3
[1 4 9] 3 3

NewCounter is a common Go idiom: a constructor function that wraps new(Counter) so callers don’t have to know about allocation details. Separately, make([]int, 0, 3) creates a slice with length 0 but capacity 3 — useful when you know roughly how many elements you’ll append and want to avoid the repeated reallocations that would happen if you started from a truly empty, zero-capacity slice.

How it works step by step

When you call new(T), the runtime: (1) determines the size of T from its type information, (2) allocates that many bytes (on the heap, unless the compiler’s escape analysis proves the pointer never leaves the current function, in which case it may live on the stack), (3) zeroes every byte, and (4) returns the address as a *T. No constructor logic runs — Go has no concept of a default constructor beyond zeroing.

When you call make([]T, length, capacity), the runtime allocates a backing array large enough for capacity elements of type T, zeroes it, and builds a three-word slice header — a pointer to the first element, the length, and the capacity — which is what gets returned and stored in your slice variable. Every subsequent append checks whether the length would exceed the capacity; if there’s room, it writes in place, if not, Go allocates a new, larger backing array (typically roughly doubling for smaller slices) and copies the old elements over.

When you call make(map[K]V), the runtime builds an hmap structure: an array of buckets, each capable of holding a handful of key/value pairs plus overflow pointers for hash collisions, along with the bookkeeping the map needs to grow and rehash itself as entries are added. This structure is what makes reading from a map cheap on average and what makes writing to a nil map (one that skipped this setup) panic — there are no buckets to write into.

When you call make(chan T, bufferSize), the runtime allocates an hchan structure containing a ring buffer sized for bufferSize values (zero-sized for an unbuffered channel), plus a mutex and wait queues that the scheduler uses to park and wake goroutines that are blocked sending to a full channel or receiving from an empty one.

Common Mistakes

Mistake 1: using new() on a map and writing to it

package main

import "fmt"

func main() {
	m := new(map[string]int)
	fmt.Println(*m == nil)
	(*m)["key"] = 1
}

This compiles, and the first line even prints true — confirming the map new gave you is still nil. The third line then panics at runtime with assignment to entry in nil map, because new only zeroed a pointer-to-map slot; it never built the underlying hash table. The fix is to use make, which builds that table for you:

package main

import "fmt"

func main() {
	m := make(map[string]int)
	m["key"] = 1
	fmt.Println(m)
}

Output:

map[key:1]

Mistake 2: forgetting that append inside a function doesn’t always update the caller’s slice

package main

import "fmt"

func grow(s []int) {
	s = append(s, 99)
	fmt.Println("inside:", s)
}

func main() {
	s := make([]int, 3, 3)
	grow(s)
	fmt.Println("outside:", s)
}

Output:

inside: [0 0 0 99]
outside: [0 0 0]

This is not a bug in make itself, but it’s a direct consequence of how slices created by make behave: a slice header is passed by value, so grow receives its own copy of the header. Because the original slice’s length already equals its capacity (3 and 3), append inside grow has to allocate an entirely new backing array — and that new array is only visible through grow‘s local copy of the header. The caller’s s still points at the old array. The fix is to return the (possibly reallocated) slice and reassign it in the caller:

package main

import "fmt"

func grow(s []int) []int {
	s = append(s, 99)
	fmt.Println("inside:", s)
	return s
}

func main() {
	s := make([]int, 3, 3)
	s = grow(s)
	fmt.Println("outside:", s)
}

Output:

inside: [0 0 0 99]
outside: [0 0 0 99]

Best Practices

  • Always use make, never new, to create slices, maps, and channels — new compiles for these types but leaves you with an unusable nil value behind a pointer.
  • Prefer a composite literal like &Point{X: 1, Y: 2} over new(Point) when you want to set fields at construction time in one expression; reserve new for cases where the zero value is genuinely what you want.
  • When you have a reasonable estimate of how many elements a slice or map will hold, pass it to make as a capacity (for slices) or size hint (for maps) to avoid repeated reallocation and rehashing as the collection grows.
  • Never write to a map before it has been created with make (or a map literal like map[string]int{}) — a nil map is safe to read from (it returns zero values) but panics on write.
  • Decide deliberately between an unbuffered channel (make(chan T), which forces sender and receiver to rendezvous) and a buffered one (make(chan T, n), which lets sends proceed without a waiting receiver until the buffer fills).
  • Remember that append‘s result should always be reassigned to a variable (s = append(s, x)) since it may or may not return a new backing array; never assume the original slice was mutated in place.

Practice Exercises

Exercise 1: Write a function NewPoint(x, y int) *Point that uses new to allocate a Point and then sets its X and Y fields before returning the pointer. Then rewrite it using a composite literal (&Point{X: x, Y: y}) instead, and consider which version is more idiomatic Go.

Exercise 2: Start with var m map[string]int (a nil map). Write code that checks whether it’s nil, initializes it with make if so, then adds a couple of entries and prints the map. What would happen if you skipped the make step and tried to add entries directly?

Exercise 3: Create a buffered channel of integers with capacity 4 using make. Send the first five square numbers (1, 4, 9, 16, 25) into it, receiving each one immediately after sending so the buffer never overflows, and print them in the order received. Then predict what would happen if you tried to send all five before receiving any, given the buffer only holds 4.

Summary

  • new(T) works on any type, allocates zeroed memory, and returns a pointer *T.
  • make(T, ...) works only on slices, maps, and channels, and returns a fully initialized, ready-to-use value of type T itself — not a pointer.
  • The zero value of a slice, map, or channel is nil; make builds the real internal structure (backing array, hash table, or ring buffer) that a plain zeroed value lacks.
  • Writing to a nil map panics at runtime — always initialize maps with make (or a literal) before writing.
  • A nil slice is safe to append to, but make lets you pre-size the backing array to avoid unnecessary reallocations.
  • Composite literals like &Point{...} are usually more idiomatic than new(Point) when you need to set fields immediately.
  • Passing a capacity or size hint to make for slices and maps is a simple, effective performance optimization when you know roughly how much data is coming.