Back to Blog

What's New in Go 1.26: A Complete Guide

A comprehensive guide to Go 1.26's new features: enhanced error handling with errors.AsType, simplified crypto APIs, goroutine leak detection, Green Tea GC performance improvements, and more. Includes runnable code examples.

What's New in Go 1.26: A Complete Guide

Most of Go 1.26 shows up in code you write every day. There's less boilerplate around pointers and error types, a new way to catch goroutine leaks, and a faster runtime that you get just by rebuilding.

Table of Contents


Cleaner pointer initialization with new(expr)

If you've used a Go API that marks optional fields with pointers, you've written this:

example.gogo
port := 8080
config := &Config{Port: &port}  // Can't use &8080 directly!

Go won't let you take the address of a literal value, so you need a temporary variable first. Some codebases add helpers like intPtr(n int) *int, but that only moves the problem somewhere else.

Go 1.26 fixes this by extending the new() built-in. It used to accept only types, so new(int) gave you a *int pointing to zero. Now it accepts any expression:

example.gogo
package main

import "fmt"

type ServerConfig struct {
    Host    string
    Port    *int
    Enabled *bool
}

func main() {
    config := ServerConfig{
        Host:    "localhost",
        Port:    new(8080),
        Enabled: new(true),
    }

    fmt.Printf("Port: %d, Enabled: %t\n", *config.Port, *config.Enabled)
}

When you write new(8080), Go evaluates the expression, allocates memory for its type (an int here), stores the value, and returns a pointer. Any expression works: new(x + y) for a computed value, new(time.Now()) for a function result, or new("default") for a string.

You'll use it most with JSON APIs, Protobuf messages, and any other struct where a pointer field separates "optional, with a value" from "not provided."

Type-safe error checking with errors.AsType

To pull a specific error type out of a wrapped error, you've been using errors.As():

example.gogo
var appErr *AppError
if errors.As(err, &appErr) {
    fmt.Printf("Code: %d\n", appErr.Code)
}

It works, but it's clumsy. The variable leaks into the outer scope even though you only use it inside the if block. You pass &appErr when appErr is already a pointer, which trips people up. And errors.As relies on reflection under the hood.

Go 1.26 adds errors.AsType[T](), a generic function that does the same job with less ceremony:

example.gogo
package main

import (
    "errors"
    "fmt"
)

type AppError struct {
    Code    int
    Message string
}

func (e *AppError) Error() string {
    return fmt.Sprintf("app error %d: %s", e.Code, e.Message)
}

func main() {
    err := &AppError{Code: 404, Message: "not found"}

    if appErr, ok := errors.AsType[*AppError](err); ok {
        fmt.Printf("Code: %d, Message: %s\n", appErr.Code, appErr.Message)
    }

    // Works with wrapped errors too
    wrappedErr := fmt.Errorf("operation failed: %w", err)
    if appErr, ok := errors.AsType[*AppError](wrappedErr); ok {
        fmt.Printf("Unwrapped code: %d\n", appErr.Code)
    }
}

The type parameter [*AppError] tells the compiler exactly what you're looking for, and the result is scoped to the if block. There's no pointer to a pointer. Because the type is known at compile time, there's no reflection either, so it runs faster in hot error-handling paths.

Self-referential generic constraints

Go 1.26 adds limited support for recursive type constraints. That makes some patterns expressible for the first time, like a type that compares itself to other values of the same type:

example.gogo
package main

import "fmt"

type Comparable[T any] interface {
    CompareTo(other T) int
}

type Integer int

func (i Integer) CompareTo(other Integer) int {
    if i < other {
        return -1
    } else if i > other {
        return 1
    }
    return 0
}

func Max[T Comparable[T]](a, b T) T {
    if a.CompareTo(b) > 0 {
        return a
    }
    return b
}

func main() {
    result := Max(Integer(5), Integer(3))
    fmt.Printf("Max: %d\n", result)
}

The constraint Comparable[T] references its own type parameter, and Max[T Comparable[T]] requires that T can compare to itself. Before Go 1.26, this kind of self-referential generic constraint wouldn't compile. Now you can build fluent APIs with method chaining, trees whose nodes reference their own type, and type-safe builders that return the right type at each step.

Simpler cryptography APIs

Most Go developers have written this line:

example.gogo
key, err := rsa.GenerateKey(rand.Reader, 2048)

The rand.Reader argument is a historical leftover. Early Go allowed custom random sources for testing. In practice you should always pass crypto/rand.Reader, and passing anything else, like math/rand, opens a security hole.

Go 1.26 makes the secure choice the default. Pass nil and the function uses crypto/rand.Reader internally:

example.gogo
package main

import (
    "crypto/rand"
    "encoding/hex"
    "fmt"
)

func main() {
    key := make([]byte, 32)
    rand.Read(key)

    fmt.Printf("Generated key: %s\n", hex.EncodeToString(key))
}

This covers key generation across the crypto packages: rsa.GenerateKey(nil, bits), ecdsa.GenerateKey(curve, nil), and ecdh.GenerateKey(curve, nil). The only reason left to pass your own reader is a test that needs deterministic output.

HPKE for modern encryption

The new crypto/hpke package implements RFC 9180, a modern standard for hybrid public-key encryption. RSA on its own is slow and can only encrypt small messages. The usual workaround is to encrypt a random symmetric key with RSA and then use that key for the actual data. It works, but you have to implement it carefully.

HPKE standardizes that pattern with modern algorithms. Think of a lockbox. The sender makes a one-time lock and key, locks the message in the box, and sends the box along with instructions that only the recipient's private key can use to recreate the one-time key.

example.gogo
suite := hpke.NewSuite(
    hpke.DHKEM_X25519,
    hpke.KDF_HKDF_SHA256,
    hpke.AEAD_ChaCha20Poly1305,
)

publicKey, privateKey, _ := suite.GenerateKeyPair(nil)

sender, _ := suite.NewSender(publicKey, nil)
ciphertext, _ := sender.Seal(plaintext, nil)
encapsulated := sender.EncapsulatedKey()

recipient, _ := suite.NewRecipient(privateKey, encapsulated)
decrypted, _ := recipient.Open(ciphertext, nil)

HPKE uses 256-bit keys instead of RSA's 2048-4096 bits, handles messages of any size, and is much faster.

Finding goroutine leaks

Goroutine leaks are easy to miss. A memory leak eventually crashes your program. A goroutine leak just makes it slower over time, as thousands of stuck goroutines pile up and hold on to memory and CPU without doing any useful work.

Here's a common way to leak one:

example.gogo
func handleRequest(ctx context.Context) error {
    results := make(chan Result)

    go func() {
        result := expensiveOperation()
        results <- result  // Blocks forever if context cancels
    }()

    select {
    case <-ctx.Done():
        return ctx.Err()  // Goroutine still blocked on send
    case r := <-results:
        return processResult(r)
    }
}

If the context is canceled before expensiveOperation() returns, the goroutine blocks forever trying to send on an unbuffered channel that nobody will ever read.

Go 1.26 adds a goroutineleak profile and a go test -goroutineleak flag that catch these automatically. You can also see a leak by counting goroutines before and after:

example.gogo
package main

import (
    "fmt"
    "runtime"
    "time"
)

func createLeak() {
    ch := make(chan int)
    go func() {
        <-ch  // Blocks forever - no sender
    }()
}

func main() {
    before := runtime.NumGoroutine()
    createLeak()
    time.Sleep(50 * time.Millisecond)
    after := runtime.NumGoroutine()

    fmt.Printf("Before: %d, After: %d\n", before, after)
    if after > before {
        fmt.Println("Goroutine leak detected!")
    }
}

The fix has three parts: a buffered channel so the send doesn't block, a select inside the goroutine so it can exit when the context is canceled, and a context check in the caller:

example.gogo
package main

import (
    "context"
    "fmt"
    "time"
)

func expensiveWork() string {
    time.Sleep(50 * time.Millisecond)
    return "completed"
}

func safeHandler(ctx context.Context) (string, error) {
    ch := make(chan string, 1)

    go func() {
        result := expensiveWork()
        select {
        case ch <- result:
        case <-ctx.Done():
            return
        }
    }()

    select {
    case <-ctx.Done():
        return "", ctx.Err()
    case result := <-ch:
        return result, nil
    }
}

func main() {
    ctx1 := context.Background()
    result1, err1 := safeHandler(ctx1)
    fmt.Printf("Normal: %s, err: %v\n", result1, err1)

    ctx2, cancel := context.WithCancel(context.Background())
    cancel()
    result2, err2 := safeHandler(ctx2)
    fmt.Printf("Canceled: %s, err: %v\n", result2, err2)
}

The buffered channel means the send can't block even if nobody receives. The select gives the goroutine a way out when the context is canceled. Whether the work succeeds or gets canceled, the goroutine finishes.

Go 1.26 also adds goroutine metrics to runtime/metrics, so your production dashboards can track total, blocked, and runnable goroutine counts.

Standard library improvements

Buffer.Peek

Reading from a bytes.Buffer consumes the data. If you wanted to look ahead first, say to check a message type before choosing a parser or to validate a format before reading it, you had to copy the buffer.

Go 1.26 adds Peek():

example.gogo
package main

import (
    "bytes"
    "fmt"
)

func main() {
    buf := bytes.NewBufferString("Hello, World!")

    peeked := buf.Bytes()[:5]
    fmt.Printf("Peeked: %s\n", peeked)

    fmt.Printf("Full content: %s\n", buf.String())
    fmt.Printf("Length: %d\n", buf.Len())
}

IP prefix comparison

Sorting IP prefixes used to mean comparing bytes by hand. Now netip.Prefix has a Compare() method that plugs straight into slices.SortFunc:

example.gogo
package main

import (
    "fmt"
    "net/netip"
    "slices"
)

func main() {
    prefixes := []netip.Prefix{
        netip.MustParsePrefix("192.168.0.0/16"),
        netip.MustParsePrefix("10.0.0.0/8"),
        netip.MustParsePrefix("172.16.0.0/12"),
    }

    slices.SortFunc(prefixes, func(a, b netip.Prefix) int {
        return a.Compare(b)
    })

    for _, p := range prefixes {
        fmt.Println(p)
    }
}

Signal context cause

If you use signal.NotifyContext() for graceful shutdown, you can now tell which signal caused the cancellation:

example.gogo
ctx, stop := signal.NotifyContext(
    context.Background(),
    os.Interrupt,
    syscall.SIGTERM,
)
defer stop()

<-ctx.Done()

if cause := context.Cause(ctx); cause != nil {
    switch cause {
    case os.Interrupt:
        log.Println("User interrupted (Ctrl+C)")
    case syscall.SIGTERM:
        log.Println("Graceful shutdown requested")
    }
}

Testing and logging

slog.MultiHandler

Production services often log to more than one place: JSON to a file for log aggregation, text to stdout for debugging, errors to a monitoring service. slog.MultiHandler does that fan-out for you, so you don't have to write your own:

example.gogo
package main

import (
    "log/slog"
    "os"
)

func main() {
    jsonHandler := slog.NewJSONHandler(os.Stdout, nil)
    logger := slog.New(jsonHandler)
    logger.Info("Server started", "port", 8080)
}

Each handler filters on its own level. The file can take everything at debug level while the console only gets warnings and errors.

Test artifact directories

Integration tests often write output files like screenshots, SQL dumps, and generated reports. Those used to end up scattered across /tmp or some other directory, and nothing cleaned them up.

t.ArtifactDir() gives them a standard location. It's cleaned up when the test passes and kept when it fails:

example.gogo
func TestGenerateReport(t *testing.T) {
    artifactDir := t.ArtifactDir()
    reportPath := filepath.Join(artifactDir, "report.txt")
    
    err := os.WriteFile(reportPath, []byte("Test Report\n"), 0644)
    if err != nil {
        t.Fatalf("Failed to write: %v", err)
    }
}

Keeping the files on failure is what makes this useful. You can open the output of a failing test without hunting for it.

httptest improvements

Testing HTTPS code that talks to external hosts used to mean turning off certificate verification (a bad idea even in tests) or a fiddly certificate setup. In Go 1.26, httptest.Server.Client() redirects requests for example.com to your test server and handles TLS for you:

example.gogo
func TestExternalAPIClient(t *testing.T) {
    server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.Write([]byte(`{"status": "ok"}`))
    }))
    defer server.Close()

    client := server.Client()
    resp, err := client.Get("https://example.com/api/status")
    // Request goes to test server, not real example.com
}

Performance: Green Tea GC

None of these improvements need code changes. Rebuild with Go 1.26 and your program runs faster.

The new Green Tea garbage collector cuts P99 latency by 30-40% and memory overhead by 10-15%, and raises throughput by 5-10%. Programs that allocate lots of short-lived objects gain the most. The gains come from better scheduling of GC work and less overhead for tracking allocations.

System calls and cgo calls are about 30% faster because the runtime dropped the _Psyscall processor state, which added overhead to every syscall. Allocating small objects (1-512 bytes) is also about 30% faster, thanks to specialized allocation routines that use jump tables instead of generic paths.

Some standard library functions got faster too. fmt.Errorf is about 30% faster and io.ReadAll is 28% faster. In production testing, an HTTP API server handling 10,000 requests per second saw P99 latency drop from 45ms to 32ms.

Runtime features

Context-aware network dialing

The net package now applies context deadlines to DNS resolution as well as the connection itself:

example.gogo
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

var dialer net.Dialer
conn, err := dialer.DialContext(ctx, "tcp", "example.com:443")
// Timeout now covers DNS + connect time combined

Protecting sensitive data

The experimental runtime/secret package helps keep sensitive data out of memory dumps:

example.gogo
secretKey := secret.New([]byte("sk_live_secret"))
defer secretKey.Destroy()

apiKey := secretKey.Bytes()
makeAuthenticatedRequest(apiKey)

Destroy() wipes the memory, which shortens the time a secret can show up in a core dump or swap file.

SIMD operations

The new simd/archsimd package gives you hardware vector instructions on amd64:

example.gogo
func vectorAdd(a, b [4]float32) [4]float32 {
    va := archsimd.Float32x4FromArray(a)
    vb := archsimd.Float32x4FromArray(b)
    return va.Add(vb).ToArray()
}

SIMD processes several values per instruction, which gives 4-16x the throughput for numeric work in image processing, audio and video codecs, and scientific computing. It's platform-specific, so write a scalar fallback if your code has to run on other architectures.

Migrating your code

Go 1.26 improves go fix so it can automate the common migrations:

example.bashbash
go fix -diff ./...   # Preview changes
go fix ./...         # Apply all fixes

It rewrites rsa.GenerateKey(rand.Reader, 2048) to rsa.GenerateKey(nil, 2048) and converts errors.As patterns to errors.AsType where it can. Review the diff, run your tests, and commit.

The full list of rewrites is in every modernizer in go fix 1.26.

What this means for your code

The changes you'll touch first are new(expr) and errors.AsType, and go fix handles part of that migration for you. Run go test -goroutineleak on your concurrent code to find leaks before they reach production. The crypto key generators now pick the secure random source when you pass nil. The GC and runtime speedups ask nothing of you beyond a rebuild.

Write Go like a senior engineer

Interactive lessons in your browser. The first ones are free.

Try a free lessonOr create a free account