40. `new` and `make`

📖 Reading · 13 min
💡 Most code boxes below are live — edit one and hit Run. Boxes without a Run button are reference-only (they can't run in your browser).

Go has two allocation built-ins and they are not interchangeable. The difference is one sentence: make is only for slices, maps and channels; new is what you reach for anywhere else. new(T) will accept any type — including those three — but only make gives you one that's ready to use. This lesson explains why that split exists.

new(T) — zeroed memory, pointer back

package main

import "fmt"

func main() {
    p := new(int)
    fmt.Println(*p, p != nil)

    *p = 42
    fmt.Println(*p)

    type Point struct{ X, Y int }
    q := new(Point)
    q.X = 3
    fmt.Printf("%+v\n", *q)
}

new(T) allocates a zeroed T and returns a *T. That's all it does.

You will hardly ever type it, because &T{} does the same thing and lets you set fields at the same time:

package main

import "fmt"

type Config struct {
    Host string
    Port int
}

func main() {
    a := new(Config)
    b := &Config{}
    c := &Config{Host: "localhost", Port: 8080}

    fmt.Printf("%+v\n%+v\n%+v\n", *a, *b, *c)
}

new(Config) and &Config{} are identical. Go programmers write the second form, because the third form is right there when you need to fill fields in. new survives mostly for basic types — new(int) when you need a *int without a variable to point at.

new(expr) — a pointer to a value, since Go 1.26

That last case used to be genuinely annoying. To get a *string holding "hello" you needed a variable to take the address of, or a one-line generic helper that every codebase ended up writing:

// the old way — you cannot write &"hello"
s := "hello"
p := &s

func ptr[T any](v T) *T { return &v }   // ...or this, in every project

Go 1.26 extended new to accept an expression, not just a type, which removes the dance:

p := new("hello")           // *string pointing at "hello"
q := new(int64(300))        // *int64 pointing at 300
r := new(Point{X: 1, Y: 2}) // *Point

new(v) allocates, copies v in, and returns a pointer to the copy — a copy, so mutating *p afterwards leaves the original variable alone. It's the answer to optional JSON fields and API clients that want *int everywhere.

Note what this does to the sentence at the top of the lesson: new now takes either a type or a value, so "new is for everything else" is even more clearly a rule of thumb than a definition. make is still the only way to get a usable slice, map or channel.

⚠️ Don't run those boxes here — and don't trust the interpreter if you do. The in-browser engine is built on an older Go that predates new(expr), and it does not reject the code. It silently returns a pointer to the zero value: new(int64(300)) prints 0, not 300. This is one of the few places the interpreter disagrees with compiled Go rather than simply failing, which is why these boxes are reference-only.

make(T, ...) — initialised, ready to use

Slices, maps and channels aren't just memory. Each has internal bookkeeping — a backing array, a hash table, a queue and locks — that must be set up before the value works at all. That's make's job:

package main

import "fmt"

func main() {
    s := make([]int, 3, 10)
    m := make(map[string]int)
    ch := make(chan int, 2)

    s[0] = 1
    m["key"] = 1
    ch <- 1

    fmt.Println(s, len(s), cap(s))
    fmt.Println(m)
    fmt.Println(len(ch), <-ch)
}

make returns the value, not a pointer — because a slice header, a map header and a channel handle are already small reference values. Taking a pointer to them is almost always a mistake.

What goes wrong if you mix them up

package main

import "fmt"

func main() {
    m := new(map[string]int)
    fmt.Println("new gives a pointer to a nil map:", *m == nil)
    // (*m)["key"] = 1 // panics: assignment to entry in nil map

    good := make(map[string]int)
    good["key"] = 1
    fmt.Println("make gives a working map:", good)

    var s []int
    s = append(s, 1)
    fmt.Println("nil slices are fine to append to:", s)
}

new(map[string]int) gives you a pointer to a nil map — allocated, zeroed and useless, because a zeroed map header has no hash table behind it. That's the practical reason for the split: new zeroes, make initialises.

Slices are the forgiving case: a nil slice appends fine, so var s []int is idiomatic. Maps and channels are not: writing to a nil map panics, and sending on a nil channel blocks forever.

The decision table

what you want write
a struct value T{...}
a pointer to a struct &T{...}
a slice you'll append to var s []T
a slice of known size make([]T, n) or make([]T, 0, n)
a map make(map[K]V) or map[K]V{}
a channel make(chan T) / make(chan T, n)
a pointer to a basic type new(int)

Reading that table top to bottom, new appears once. That's an accurate reflection of how often you'll use it.

Pre-sizing pays off

make takes a size hint for maps too, and it works the same way as slice capacity — fewer rehashes as the map grows:

package main

import "fmt"

func main() {
    sized := make(map[int]string, 1000)
    for i := 0; i < 1000; i++ {
        sized[i] = "value"
    }

    unsized := make(map[int]string)
    for i := 0; i < 1000; i++ {
        unsized[i] = "value"
    }

    fmt.Println(len(sized), len(unsized))
    fmt.Println("same result, fewer allocations on the first one")
}

The hint isn't a limit — the map still grows past it. It just skips the early rehashing when you already know roughly how many entries you'll have.

Composite literals do the work too

For maps and slices with known contents, a literal is shorter than make plus assignments:

package main

import "fmt"

func main() {
    ports := map[string]int{
        "http":  80,
        "https": 443,
        "ssh":   22,
    }

    grid := [][]int{
        {1, 2, 3},
        {4, 5, 6},
    }

    byName := map[string][]string{
        "fruit": {"apple", "pear"},
        "veg":   {"carrot"},
    }

    fmt.Println(ports["https"])
    fmt.Println(grid[1][2])
    fmt.Println(byName["fruit"])
}

Note the nested literals in grid and byName — you can drop the inner []int and []string, because Go already knows the element type. The same elision you saw in slices of structs.

Your turn

Build a map[string][]string index of words by their first letter, using make, and print the entries for a and b:

a: [apple avocado]
b: [banana]
package main

import "fmt"

func main() {
    words := []string{"apple", "banana", "avocado"}
    // build index := map[string][]string keyed by first letter
    fmt.Println("a:", index["a"])
    fmt.Println("b:", index["b"])
}
package main

import "fmt"

func main() {
    words := []string{"apple", "banana", "avocado"}

    index := make(map[string][]string)
    for _, w := range words {
        first := string(w[0])
        index[first] = append(index[first], w)
    }

    fmt.Println("a:", index["a"])
    fmt.Println("b:", index["b"])
}

Note index[first] = append(index[first], w) on a key that doesn't exist yet: the read gives a nil slice, append creates one, and the assignment stores it. Maps and nil slices working together, with no initialisation step.

Next: where all this memory actually lives.