Skip to main content

What's New in Go 1.26 — Hands-On

A hands-on walkthrough of Go 1.26's highlights: less boilerplate, a faster garbage collector, stronger generics, and improved observability — with runnable examples you can edit in your browser.

gogo1.26releaseinteractivetour
This post was generated with AI assistance and hasn't been fully verified, some details may be inaccurate. Report an error.

Go 1.26 is all about doing more with less ceremony. Pointer initialization that used to take three lines now takes one, error matching no longer needs a pre-declared variable, and the runtime ships a new garbage collector that cuts GC overhead for most workloads. Below you'll find every highlight organized by theme, with editable snippets — hit Run to try them yourself.

Every snippet runs on Go 1.26 via Codapi sandboxes directly in your browser — no local toolchain required.


Less boilerplate

Pointer initialization with new(expr)

Until now, new only accepted a type. Getting a pointer to a concrete value required an intermediate variable:

go1.25
interactive
// Go 1.25: two steps to get a pointer to a value
val := 100
ptr := &val
fmt.Println(*ptr)

Starting in Go 1.26 you can pass any expression to new and get back a pointer to a freshly allocated copy:

go1.26
interactive
// Go 1.26: one step
ptr := new(100)
fmt.Println(*ptr)

Where this really shines is optional struct fields. APIs serialized as JSON or protobuf often use *T to distinguish "not set" from the zero value. Before you had to declare a helper variable; now it's a one-liner:

go1.26
interactive
f := Feature{Name: "dark-mode", Enabled: new(true)}
data, _ := json.Marshal(f)
fmt.Println(string(data))

It works with composite literals and function return values too:

go1.26
interactive
// slice literal
tags := new([]string{"alpha", "beta", "gamma"})
fmt.Println(*tags)
// struct literal
type Dimensions struct{ W, H int }
screen := new(Dimensions{W: 1920, H: 1080})
fmt.Println(*screen)
// function return value
greet := func() string { return "hello, world" }
msg := new(greet())
fmt.Println(*msg)

Type-safe error matching with errors.AsType

errors.As requires a pre-declared target variable and uses reflection under the hood. The new generic errors.AsType[E] removes both pain points.

The old way:

go1.25
interactive
package main
import (
"encoding/json"
"errors"
"fmt"
"strings"
)
func main() {
_, err := decode(`{"broken`)
err = fmt.Errorf("config load: %w", err)
// Go 1.25 — target variable lives outside the if block
var synErr *json.SyntaxError
if errors.As(err, &synErr) {
fmt.Printf("syntax error at byte %d\n", synErr.Offset)
}
}
func decode(raw string) (map[string]any, error) {
var out map[string]any
err := json.NewDecoder(strings.NewReader(raw)).Decode(&out)
return out, err
}

And the new way — everything scoped inside the if:

go1.26
interactive
package main
import (
"encoding/json"
"errors"
"fmt"
"strings"
)
func main() {
_, err := decode(`{"broken`)
err = fmt.Errorf("config load: %w", err)
// Go 1.26 — no pre-declared variable, no reflection
if se, ok := errors.AsType[*json.SyntaxError](err); ok {
fmt.Printf("syntax error at byte %d\n", se.Offset)
}
}
func decode(raw string) (map[string]any, error) {
var out map[string]any
err := json.NewDecoder(strings.NewReader(raw)).Decode(&out)
return out, err
}

Because the type is checked at compile time, you also avoid the runtime panics that errors.As can produce when called with the wrong kind of target. Here is a dispatcher that classifies multiple error types:

go1.26
interactive
package main
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
)
func diagnose(err error) string {
if se, ok := errors.AsType[*json.SyntaxError](err); ok {
return fmt.Sprintf("bad JSON at byte %d", se.Offset)
}
if ue, ok := errors.AsType[*url.Error](err); ok {
return fmt.Sprintf("URL error (%s %s)", ue.Op, ue.URL)
}
return "unrecognized: " + err.Error()
}
func main() {
// JSON error
var m map[string]any
jerr := json.NewDecoder(strings.NewReader(`{`)).Decode(&m)
fmt.Println(diagnose(jerr))
// URL error
_, uerr := url.Parse("://missing-scheme")
if uerr != nil {
fmt.Println(diagnose(uerr))
}
}

Peeking into a bytes.Buffer

bytes.Buffer gains a Peek(n) method that returns the next n bytes without consuming them. This is handy for protocol parsers that need to inspect a header before deciding how to read the rest:

go1.26
interactive
buf := bytes.NewBufferString("GET /index.html HTTP/1.1")
// Look at the first 3 bytes — the read cursor stays put
method, err := buf.Peek(3)
fmt.Printf("method=%q err=%v\n", method, err)
// Skip past "GET "
buf.Next(4)
// Peek at the path
path, err := buf.Peek(11)
fmt.Printf("path=%q err=%v\n", path, err)
// The rest of the buffer is still intact
fmt.Printf("remaining=%q\n", buf.String())

If you ask for more bytes than remain, Peek returns whatever is left along with io.EOF:

go1.26
interactive
buf := bytes.NewBufferString("OK")
data, err := buf.Peek(64)
fmt.Printf("data=%q err=%v\n", data, err)

Faster by default

Green Tea garbage collector

The Green Tea GC was introduced as an opt-in experiment in Go 1.25. In 1.26 it becomes the default collector. Instead of chasing individual object pointers scattered across the heap, it walks memory in contiguous regions, which plays much better with modern CPU caches and allows more parallel scanning.

The Go team's benchmarks show GC overhead dropping between 10 % and 40 % for allocation-heavy programs, with additional gains on recent Intel and AMD microarchitectures.

Here is a synthetic workload that creates many short-lived allocations — exactly the scenario where Green Tea helps most:

go1.26
interactive
const N = 250_000
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
// Allocate N small structs and keep references alive
type Coord struct{ Lat, Lng float64 }
coords := make([]Coord, N)
for i := range coords {
coords[i] = Coord{Lat: float64(i) * 0.01, Lng: float64(i) * -0.01}
}
runtime.KeepAlive(coords)
runtime.GC()
runtime.ReadMemStats(&after)
fmt.Printf("GC cycles : %d\n", after.NumGC-before.NumGC)
fmt.Printf("Pause total: %.2f ms\n",
float64(after.PauseTotalNs-before.PauseTotalNs)/1e6)
fmt.Printf("Heap in use: %.1f MiB\n",
float64(after.HeapInuse)/1024/1024)

If you need the old collector for any reason, build with GOEXPERIMENT=nogreenteagc. That escape hatch is expected to disappear in Go 1.27.


io.ReadAll performance overhaul

io.ReadAll was rewritten internally. It now grows its scratch buffer exponentially and produces a final slice trimmed to the exact size needed. Benchmarks show roughly double the throughput with half the peak memory — and the function signature hasn't changed at all.

go1.26
interactive
// Simulate reading a ~400 KiB HTTP body
line := "The quick brown gopher jumps over the lazy mutex.\n"
body := strings.Repeat(line, 8_000)
data, err := io.ReadAll(bytes.NewBufferString(body))
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Printf("Read %d bytes\n", len(data))
fmt.Printf("len == cap: %v (final slice is tightly sized)\n", len(data) == cap(data))

testing.B.Loop — inlining fixed

B.Loop() was added in Go 1.25 as the modern replacement for the manual for i := 0; i < b.N; i++ pattern. A regression in 1.25 prevented the loop body from being inlined, which could artificially inflate allocs/op. Go 1.26 fixes that.

The classic b.N pattern:

go1.25
interactive
input := "the quick brown fox jumps over the lazy dog"
r := testing.Benchmark(func(b *testing.B) {
var sink int
for i := 0; i < b.N; i++ {
sink = countVowels(input)
}
_ = sink
})
fmt.Printf("b.N style: %d ns/op %d allocs/op\n", r.NsPerOp(), r.AllocsPerOp())

And the cleaner b.Loop() form, now with correct inlining:

go1.26
interactive
input := "the quick brown fox jumps over the lazy dog"
r := testing.Benchmark(func(b *testing.B) {
var sink int
for b.Loop() {
sink = countVowels(input)
}
_ = sink
})
fmt.Printf("b.Loop style: %d ns/op %d allocs/op\n", r.NsPerOp(), r.AllocsPerOp())

Stronger generics & reflection

Recursive type constraints

Before Go 1.26 you couldn't write a generic constraint that references the constrained type parameter — the compiler rejected it. Now it works:

go1.26
interactive
fmt.Println(Clamp(Score(150), Score(0), Score(100)))
fmt.Println(Clamp(Score(-5), Score(0), Score(100)))
fmt.Println(Clamp(Score(42), Score(0), Score(100)))

This unlocks patterns like self-referential builder interfaces and strongly-typed collection contracts that were previously impossible without sacrificing type safety.


reflect — iterator methods

reflect.Type and reflect.Value now expose .Fields() and .Methods() iterators that work directly with for range. No more manual indexing.

Type.Fields — walk struct metadata:

go1.26
interactive
for f := range reflect.TypeFor[Server]().Fields() {
fmt.Printf("%-8s tag=%s\n", f.Name, f.Tag.Get("yaml"))
}

Value.Fields — iterate over field metadata and runtime values together:

go1.26
interactive
srv := Server{Addr: "0.0.0.0", Port: 443, TLS: true, Workers: 8}
for sf, v := range reflect.ValueOf(srv).Fields() {
fmt.Printf("%-8s = %v\n", sf.Name, v)
}

.Methods() works the same way for method sets. The old for i := range t.NumField() pattern still compiles, but the new iterators are shorter and compose nicely with other iterator-based APIs.


Better observability

Fan-out logging with slog.NewMultiHandler

slog.NewMultiHandler sends each log record to every handler you give it. Its Enabled method returns true if any handler accepts the level, so no messages are silently swallowed.

A single handler to start:

go1.26
interactive
h := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
})
log := slog.New(h)
log.Debug("starting up", "version", "1.26.0")
log.Info("listening", "addr", ":8080")

Now wire a text handler (all levels) and a JSON handler (warnings and above) into a single logger:

go1.26
interactive
console := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
})
var jsonBuf bytes.Buffer
structured := slog.NewJSONHandler(&jsonBuf, &slog.HandlerOptions{
Level: slog.LevelWarn,
})
log := slog.New(slog.NewMultiHandler(console, structured))
log.Debug("loading config", "file", "app.toml")
log.Info("server ready", "workers", 4)
log.Warn("high latency", "p99_ms", 320)
log.Error("disk full", "mount", "/data")
fmt.Println("\n── JSON (warn+ only) ──")
fmt.Print(jsonBuf.String())

Signal-aware context cancellation

When signal.NotifyContext catches a signal, context.Cause now returns the actual signal instead of the generic context.Canceled. Combined with errors.AsType, you can branch on exactly which signal arrived:

go1.26
interactive
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// Fire SIGTERM at ourselves after a short delay
go func() {
time.Sleep(20 * time.Millisecond)
proc, _ := os.FindProcess(os.Getpid())
proc.Signal(syscall.SIGTERM)
}()
<-ctx.Done()
cause := context.Cause(ctx)
fmt.Println("ctx.Err() =", ctx.Err())
fmt.Println("context.Cause() =", cause)
// Use AsType to identify the exact signal
if sig, ok := errors.AsType[syscall.Signal](cause); ok {
fmt.Printf("signal %d (%s) — starting graceful shutdown\n", int(sig), sig)
}

Goroutine leak detector (experimental)

A new goroutineleak pprof profile identifies goroutines that are permanently stuck on a channel or sync primitive whose counterpart is unreachable. The collector looks at the reachability graph: if no runnable goroutine can ever unblock a waiting one, it's flagged as a leak.

A minimal leaking example — the sender blocks forever because nobody reads:

go1.26
interactive
startWorker(1) // return value discarded — goroutine leaked
startWorker(2) // same
fmt.Println("Two goroutines leaked.")
fmt.Println("Detect with: GOEXPERIMENT=goroutineleakprofile go build .")
fmt.Println("Then: curl http://localhost:6060/debug/pprof/goroutineleak")

The fix: use a buffered channel sized to the number of senders, so they can complete even if the receiver walks away:

go1.26
interactive
fmt.Println(fanOut([]int{10, 20, 30}))

Tooling

Modernized go fix

go fix was rebuilt from the ground up on the same analysis engine that powers go vet. It ships over 20 fixers that rewrite idiomatic patterns automatically and safely.

# Modernize your entire module
go fix ./...
 
# Preview the diff without writing
go fix -diff ./...
 
# Run a single fixer
go fix -stringsCut ./...

The stringsCut fixer, for example, replaces a common two-step strings.Index + slice pattern with strings.Cut:

// Before go fix
func parseHeader(line string) (string, string) {
    i := strings.Index(line, ": ")
    if i < 0 {
        return line, ""
    }
    return line[:i], line[i+2:]
}
// After go fix
func parseHeader(line string) (string, string) {
    key, val, _ := strings.Cut(line, ": ")
    return key, val
}

Library authors can also mark deprecated wrappers with //go:fix inline so that downstream callers are automatically migrated when they run go fix:

// Deprecated: use ProcessV2.
//
//go:fix inline
func Process(data []byte) error { return ProcessV2(data) }

Quick reference

AreaChange
Languagenew(expr) lets you initialize pointers in a single expression
LanguageGeneric type constraints can now be self-referential
RuntimeGreen Tea GC is the default — expect 10–40 % less GC overhead
RuntimeCGo and syscall paths are ~30 % faster
RuntimeHeap base address is randomized on 64-bit platforms
stdliberrors.AsType[T] — generic, compile-time-checked error unwrapping
stdlibio.ReadAll rewritten for ~2× throughput and ~½ peak memory
stdlibslog.NewMultiHandler sends records to multiple handlers at once
stdlibsignal.NotifyContext now exposes the signal via context.Cause
stdlibreflect gains .Fields() / .Methods() iterator methods
stdlibbytes.Buffer.Peek reads ahead without consuming
stdlibcrypto/hpke implements RFC 9180 with post-quantum KEM support
Toolsgo fix rebuilt as an analysis-based modernizer with 20+ fixers
TestingB.Loop() inlining regression fixed — accurate allocs/op again
Experimentalgoroutineleakprofile — GC-based stuck-goroutine detection

For the full changelog, see the official Go 1.26 release notes.

Comments

0/280
What's New in Go 1.26 — Hands-On