40. `new` and `make`
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))prints0, not300. 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.