The Pointer Without a Temporary Variable
You are writing a stack and want the first node without staging an empty struct somewhere:
type Node struct {
Val int
Next *Node
}
func main() {
head := new(Node) // *Node, pointing at a fully zeroed Node
head.Val = 1
head.Next = new(Node)
head.Next.Val = 2
// head: 1 -> 2 -> nil
}
new(T) does one thing: allocate storage for a T, zero it, and return a *T pointing at it. The zeroing is a guarantee, not a habit — every field gets its zero value (0, "", false, nil pointers), so head.Next starts nil and the list terminates correctly without an init step. This is the memory semantics C's malloc lacks: no uninitialized garbage, no field you forgot to set.
new and the address-of operator overlap heavily. These two lines produce equivalent results:
a := new(Player)
b := &Player{} // same zeroed Player, one word pointer
// With initial values, only the literal form works directly:
c := &Player{Name: "mira", HP: 100}
The practical rule: reach for &T{...} whenever fields are known at construction — it reads as one statement and shows the values. new(T) earns its place when you need a zeroed pointer with no values yet, when the type is built by a helper that fills it in, or when generics force a type-only expression:
func zeroedPair[T any]() (*T, *T) {
return new(T), new(T) // no literal syntax exists for an unknown T
}
Where new misleads is with types that are themselves references. new([]int) returns a *[]int — a valid pointer to a slice header that is nil. Nothing is broken; you simply got exactly what new promises (zeroed storage) when you wanted a ready-to-use slice, and the only way to write through it is (*s) = make([]int, 0, 8), a second allocation wrapped in a dereference. Slices, maps, and channels are the three types whose zero values are not fully initialized containers, and they have their own builtin for a reason — that builtin is make.
make Builds the Three Types That Need Runtime Scaffolding
A slice, a map, and a channel are small headers pointing at runtime-managed structures: backing arrays, hash buckets, channel buffers and waiter queues. Their nil zero values are readable in limited ways, but usable containers need that machinery built — which is what make does, and why its first argument must be one of exactly three types:
ids := make([]int, 5) // len 5, cap 5 — zeroed elements, real storage
hits := make(map[string]int, 64) // bucket table sized for ~64 entries
ch := make(chan int, 10) // buffered channel, room for 10 sends
Each form initializes what its type needs beyond zeroing. The slice gets a header plus a backing array of the requested length and capacity. The map gets its bucket region pre-sized by the optional hint (so a fill loop starts ahead — Maps: Insert, Lookup, and Delete). The channel gets its buffer and synchronization state, none of which a zero value provides.
Contrast the same slice built three wrong ways:
a := new([]int) // *[]int -> points at a nil slice: append needs (*a) = ...
var b []int // nil slice: reads fine, but no reserved capacity
c := make([]int, 0, 8) // ready: len 0, cap 8, appends land in place
a compiles, dereferences cleanly, and still cannot serve as an initialized buffer — the pointer is correct and the container is absent. b is the legitimate zero value you use when growth is coming through appends anyway (Slice Basics and the Slice Header). c is the answer when you know you will fill it.
Two syntax facts settle the rest. make takes a type plus size arguments — make([]int, 0, 1024), make(map[string]bool), make(chan Job, 4) — while new takes a single type and no sizes at all; new([]int, 5) will not compile. And for channels, the length/capacity arguments are not cosmetic: a zero-capacity channel blocks every send until a receiver arrives, while capacity decouples the two (Channel Basics). When sizing looks like a performance tweak, it is usually a semantics decision wearing a performance costume.
Choosing Allocation: One Table, Four Situations
Most allocation questions in everyday Go resolve in one of four columns:
| Situation | Tool | Example |
|---|---|---|
| local value, no sharing needed | var |
var total int, var buf strings.Builder |
| initialized struct or pointer | &T{...} |
cfg := &Config{Timeout: 3} |
zeroed storage as *T |
new(T) |
head := new(Node), generic new(T) |
| slice / map / channel that must work now | make |
make([]Row, 0, n) |
Notice what is missing: allocations you never see. An int declared inside a function with its address never taken usually lives in a register or on the stack — no GC object, no free list, no cost worth optimizing away. The compiler decides placement by escape analysis: take an address or return a reference, and the value moves to the heap (Heap Escape Analysis). This reframes the choice — you are selecting semantics (zeroed pointer? initialized container? plain local?), and the allocator follows.
Three habits keep the table honest in real code:
Do not allocate to avoid a copy you have not measured. func heavy(cfg *Config) for a struct with five fields is premature indirection; the copy was a few words. Pointer parameters for large structs or mutation — not for time.Duration (Pointers as Function Parameters).
Reserve capacity where the final size is known.
ids := make([]int, 0, len(rows)) // one allocation for the whole fill
for _, r := range rows {
ids = append(ids, r.ID) // never reallocates
}
Let the zero value do constructor work. bytes.Buffer, strings.Builder, and sync.Mutex are all usable from var b bytes.Buffer — no new, no make, no constructor. Types that cannot be (any struct holding a map field you write to) should own an explicit New... function (Nil Checks and Zero-Value Design). The table gives the syntax; the type's zero-value contract decides which row applies.
Quiz
1. p := new(int) — what does fmt.Println(*p) print?
2. m := new(map[string]int) compiles, and later (*m)["a"] = 1 runs. What happens?
3. You write s := new([]int, 5). What does the compiler say, and why?
4. A fill loop appends 10,000 known elements to a slice. Which allocation is idiomatic?
Flashcards
Question
What exactly does new(T) return, and how does &T{} compare?
Click to reveal answer
Answer
new(T) returns a *T pointing at a freshly zeroed T — guaranteed zero values in every field. &T{} does the same for literals you initialize: &T{Timeout: 3} allocates the T, sets the listed fields, and zeroes the rest. Prefer &T{...} when values are known; new(T) fits type-only contexts like generics.
Question
Why is make limited to slices, maps, and channels?
Click to reveal answer
Answer
Those three types have nil zero values whose headers reference no runtime structure — no backing array, no buckets, no channel machinery. make builds exactly that scaffolding plus requested size; every other type reaches full functionality from its zero value, so make would have nothing to do.
Question
What is wrong with b := new([]int) when you meant 'an empty slice ready for appends'?
Click to reveal answer
Answer
You get a *[]int whose target is a nil slice: correct pointer, unusable container. Appending requires (*b) = append((*b), v) — two steps and an extra allocation. Use make([]int, 0, cap) or simply var b []int; keep new for types where the zero value already works.
Question
When should you NOT reach for a pointer or an allocation to 'avoid copying'?
Click to reveal answer
Answer
When the value is small — a handful of fields or a scalar — the copy costs a few words, and the pointer adds an indirection plus a heap object. Pass by value until a struct is large or must be mutated; the compiler keeps unaddressed locals on the stack for free.
Question
What does make's second argument do for a map versus a slice versus a channel?
Click to reveal answer
Answer
For a slice it is length (with optional capacity); for a map it is a size hint that pre-reserves buckets, not a hard limit; for a buffered channel it is buffer capacity, which changes blocking semantics — zero means sends block until a receiver arrives. Same keyword, three different contracts.
Revision Notes
Key Takeaways
- 1. new(T) = allocated and zeroed *T; &T{...} is the same allocation with fields set — literals win when values are known
- 2. make exists because slice, map, and channel zero values lack runtime structures; it is restricted to those three types
- 3. new([]int) and new(map[K]V) are legal but trap-laden: the pointer is fine, the container behind it is nil
- 4. make's size arguments mean different things per type: slice length/cap, map size hint, channel buffer with real semantics
- 5. Allocation placement follows escape analysis — choose semantics first, and let the compiler decide stack versus heap
Interview Tips
- • "new vs make?" — one line: new returns a zeroed pointer to any type; make initializes slices, maps, and channels so they are usable immediately
- • If pressed for the deeper answer, explain *why*: those three have nil headers pointing at no runtime structure, and make builds it
- • "What's wrong with new([]int)?" — nothing syntactically; the result points at a nil slice, so appends need an extra dereference-and-assign
- • When discussing performance, say you pick semantics and let escape analysis choose the location — that signals you have actually profiled rather than guessed
Cheat Sheet
new, make, and Choosing Allocation
Decision table:
| You need | Write | Gets you |
|---|---|---|
| plain local value | var x T |
zero value, often stack-allocated |
| initialized pointer | &T{fields} |
allocated T with values set |
zeroed *T (no values yet) |
new(T) |
pointer to a fully zeroed T |
| usable slice/map/channel | make(...) |
header + runtime structures |
| reserved append capacity | make([]T, 0, n) |
empty window, no growth copies |
| ready-to-use buffer/builder | var b bytes.Buffer |
zero value already works |
Syntax: new(Type) — one argument, no sizes. make(T, len[, cap]) — only slice, map, or chan.
Code template:
func BuildIndex(rows []Row) map[string]int {
idx := make(map[string]int, len(rows)) // size hint from known input
for i, r := range rows {
idx[r.Key] = i
}
return idx
}
func Connect(addr string) *Conn {
c := &Conn{addr: addr} // initialized literal beats new(Conn)
c.dial()
return c
}
Traps: new([]int) / new(map[K]V) → valid pointer, nil container (writes panic) · make([]int, n) when you meant capacity · allocating 'just in case' before escape analysis tells you it matters.
Continue Learning
Before You Start
- No prerequisites listed.