AlgoMaster Logo

new vs make

Last Updated: May 22, 2026

High Priority
9 min read

Go gives you two built-in allocation functions, new and make. The names sound interchangeable but the behaviour isn't. new zero-allocates any type and hands back a pointer. make initializes the runtime structure behind a slice, map, or channel and hands back a ready-to-use value. This lesson covers what each one does, why Go ships both, when to pick which, and the common mistakes that come from mixing them up.

Why Go Has Two Allocation Functions

Most languages have one allocation primitive. Go has two because slices, maps, and channels aren't ordinary types. They have hidden runtime state that needs to be initialized before the value is usable, and zero memory alone isn't enough to set that state up correctly.

Take a slice. A slice value is a tiny header that holds three things: a pointer to a backing array, a length, and a capacity. Zeroing the header gives you a nil pointer, length 0, and capacity 0. That's a valid nil slice, but it has no backing array, so any attempt to append to it works only because append allocates a fresh array. A map is worse: zeroing the bytes gives you a nil map, and writing to a nil map panics at runtime. A channel is the same story; a zero-valued channel is nil, and sending on it blocks forever.

new(T) just zeros memory and returns a pointer. That works fine for a struct or an int, but it can't build the hash table behind a map or the ring buffer behind a buffered channel. So Go provides make specifically for those three built-in types. make calls into the runtime, allocates the right internal structures, and gives you back a value you can use immediately.

The two functions cover different territory. new is for "give me a zero value of type T and a pointer to it". make is for "give me a fully initialized slice, map, or channel I can use right now".

What new(T) Does

new(T) is the simpler of the two. You hand it a type, it allocates enough memory for a zero value of that type, and it returns a pointer to that memory. The returned value's type is *T. The memory is zeroed (every byte is zero), which means every field of a struct, every element of an array, and every basic type value starts at its zero value.

new(Product) allocates a Product somewhere in memory (the compiler decides whether that's the stack or the heap based on escape analysis), zeros it, and returns a *Product. The variable p is the pointer. To read or write the fields, you can use *p (dereference) or just p.Code, since Go lets you write through a pointer with the dot operator directly.

new works on any type, not just structs. You can call new(int), new(string), new([5]float64), even new(map[string]int). The result is always a *T pointing at a zeroed T.

There's nothing magic about new. The compiler treats it as a built-in, but the effect is the same as writing var x T; p := &x for an addressable temporary. We'll come back to that equivalence in a moment.

What make Does

make is specialized for three types: slices, maps, and channels. Pass it the type and any extra arguments needed for that type's initial size, and it returns a fully constructed value of that type, not a pointer to one.

The signatures are:

Each form sets up the right internal structure. For a slice, make allocates a backing array of the requested capacity and returns a slice header pointing at it. For a map, make allocates a hash table (with bucket arrays) and returns a map value referring to it. For a channel, make allocates a channel structure (with a buffer if you asked for one) and returns the channel value.

Each of these values is non-nil and immediately usable. The slice has a real backing array. The map has a real hash table. The channel has a real send buffer. None of that would be true if you'd zero-allocated them, which is why make exists in the first place.

make always returns a value of the type you asked for, not a pointer to it. That matches how slices, maps, and channels are usually passed around in Go: they're already small headers internally, so wrapping them in a pointer would add a layer of indirection for no benefit.

new(T) vs var x T; p := &x

For basic types and structs, new(T) is equivalent to declaring a var and taking its address. Both produce a pointer to a zero-valued T.

Different addresses (because they're different variables), but the pointed-to value is the same in both cases: a zero int. The compiler can put either variable on the stack or the heap depending on whether the pointer escapes. There's no performance reason to prefer one form over the other.

The same holds for structs. new(Product) and &Product{} both give you a *Product pointing at a zero Product:

Two separate allocations, two separate addresses, two identically-shaped zero Product values. The difference is purely stylistic. Most Go code uses &Product{} over new(Product) because the composite literal form scales naturally when you want to populate fields right away:

new(Product) can only ever give you the zero value; you'd have to follow it with field assignments to populate the struct. &Product{Code: "BOOK-01"} does both in one expression. That's why new for structs is rare in idiomatic Go.

What new and make Return: A Side-by-Side

The two functions have similar names but different shapes.

Propertynew(T)make(T, args...)
Allowed typesAny typeslice, map, chan only
Initializes memoryZeroes bytesSets up internal runtime structure
Return type*TT (not a pointer)
Result of new([]int)*[]int pointing at a nil slicen/a
Result of make([]int, 0)n/a[]int (non-nil, empty, usable)
Common in idiomatic GoRareCommon
Use whenYou literally want *T to a zero valueBuilding any slice, map, or channel

The flow below captures which one you actually want for each built-in type.

The diagram shows the default path for each built-in type. new doesn't appear because it isn't the idiomatic choice for any of the common cases: literals and make cover everything you actually want to do.

The new([]int) Trap

A common mistake is calling new on a slice, map, or channel type. The call compiles and runs, but the result is almost never what you want.

new([]int) returns a *[]int, which is a pointer to a nil slice header. The slice itself has no backing array. To work with the slice you'd have to dereference the pointer every time, and you'd still need to append (or otherwise initialize) before reads will give you anything useful. Writing (*s)[0] panics, because indexing past length 0 is out of range.

The fix is almost always make:

make([]int, 0, 3) returns a usable, non-nil slice with capacity 3, no pointer dance required.

The same trap applies to maps and channels. new(map[string]int) returns a *map[string]int pointing at a nil map; writing through it still panics:

new(chan int) is the same idea: a pointer to a nil channel, and sends or receives on a nil channel block forever. None of these are useful. Use make instead.

make Doesn't Work for Non-Built-In Types

The reverse mistake is trying to use make for a type that isn't a slice, map, or channel. make(*Product) doesn't compile, and neither does make(int) or make(Product):

The compiler is explicit about the rule. make is a built-in restricted to the three types that need runtime initialization. For anything else, use a composite literal, new, or var.

This restriction is a feature, not a limitation. It draws a clear line between "this type has hidden internal state that needs initialization" (slice, map, channel) and "this type is just bytes" (everything else). The line maps directly to the language design: only the three built-ins have a runtime structure that the language manages on your behalf.

A Type-by-Type Allocation Cheat Sheet

For each built-in type, the idiomatic allocation form:

TypeIdiomatic AllocationResult
intvar n intZero int, value form
*intn := new(int) or var n int; p := &nPointer to a zero int
stringvar s stringEmpty string
Product (struct)var p Product or p := Product{}Zero Product, value form
*Productp := &Product{} or p := &Product{Code: "..."}Pointer to a zero/initialized Product
[]intmake([]int, 0, n) or []int{1, 2, 3}Non-nil, ready slice
[]Productmake([]Product, 0, n) or []Product{...}Non-nil, ready slice
map[string]intmake(map[string]int) or map[string]int{...}Non-nil, ready map
chan intmake(chan int) or make(chan int, n)Non-nil, ready channel
[5]int (fixed array)var a [5]int or [5]int{1,2,3,4,5}Zero or initialized array

A few patterns worth calling out:

Pre-sized slice for known input size. When you know how many items you'll be reading into a slice, pre-size it once:

make([]Product, 0, len(codes)) allocates exactly one backing array. Without the capacity hint, repeated append calls would grow the slice several times during the loop, allocating intermediate arrays.

Empty map ready for writes. This is the most common map shape in Go:

The size hint (make(map[string]int, 100)) is optional and rarely changes correctness, but it can save a few rehashes if you know the rough final size.

Buffered channel for fan-out:

The 5 is the buffer size: up to five values can sit in the channel before a sender blocks. Channel mechanics belong to a separate topic; here, make is what gives you a real channel instead of a nil one.

When to Use new

new is rarely the right choice in idiomatic Go code. Most cases that look like they want new have a better alternative:

  • For basic types, var x T is just as good and doesn't need an import or a built-in call.
  • For structs, &Product{} is more flexible because it lets you populate fields in the same expression.
  • For slices, maps, and channels, new produces a broken pointer-to-nil and you almost certainly want make instead.

The narrow cases where new is genuinely useful:

  1. **You need a *T pointing at a zero T and you don't have a name to take the address of.** In a single expression, new(int) is sometimes cleaner than declaring an intermediate variable.
  2. You're writing generic code where the type isn't statically known. A function parameterized over T can call new(T) to allocate a zero T.
  3. You want to be explicit about allocation in API signatures. Some standard library APIs return new-allocated values, like bytes.NewBuffer (which uses a composite literal internally, but the spirit is the same).

In day-to-day code: prefer &T{} over new(T) for structs, and make for slices, maps, and channels. new survives mostly for completeness and for the cases where you want *T pointing at a zero value.

Even here, var n int; return &n does the same thing. Both compile, both produce identical results, and the choice is style. The new form is slightly shorter; the var form makes the variable explicit. Many Go codebases use neither pattern often, and prefer to pass values around rather than allocate pointers gratuitously.

A Visual Comparison of What Each Call Returns

The diagram below shows what new and make actually hand back for the most common types. The shapes matter because they explain why *[]int and []int aren't drop-in substitutes.

The green outputs are pointer-to-zero values, which is exactly what new is for. The orange outputs are ready-to-use slices, maps, and channels, which is exactly what make is for. The red path is the trap: new([]int) compiles, but the result is a pointer to a nil slice that's almost never what you wanted.

Putting It Together with an E-Commerce Example

A short snippet from a shopping cart program ties the pieces together. The cart itself is a struct, so we allocate it with a composite literal. The list of items inside the cart is a slice, so we use make. The discount lookup is a map, so we use make. And we use new exactly once, to track a counter via pointer just to show what it looks like in practice.

Five things are happening here:

  1. Cart is a struct, so &Cart{...} gives you a pointer to a populated value in one expression.
  2. Items is a slice, so make([]string, 0, capacity) gives you a pre-sized empty slice. Pre-sizing avoids growth during append.
  3. Discount is a map, so make(map[string]float64) gives you a real, empty hash table that's safe to write to.
  4. totalCarts is a *int, so new(int) is fine. var n int; totalCarts := &n would work too.
  5. Nothing is allocated with the wrong tool. There's no new([]string), no make(Cart), no new(map[string]float64).

That's the pattern you'll see in real Go code: composite literals for structs, make for the three built-ins, new only when there's no name to take the address of. Keep to those defaults and the surface area for mistakes shrinks dramatically.