Go 1.27 is coming soon, so it’s a good time to get a head start on what’s new. The official release notes are pretty dry, so here’s a hands-on version with runnable examples showing what changed and how the new behavior works.

A quick credit first: the interactive Go tours were started by Anton Zhiyanov, who wrote one for every release from Go 1.22 through Go 1.26. He’s decided to stop, so we’re picking up where he left off. His earlier tours are all still worth a read:

Thanks, Anton.

Before we start digging into the new features, let’s set the context.

This article is based on the official release notes and the Go source code, licensed under the BSD-3-Clause. This is not an exhaustive list; see the official release notes for that.

Links point to the documentation (𝗗), proposals (𝗣), most relevant commits (𝗖𝗟), and authors (𝗔) for each feature; check them out for motivation, usage, and implementation details. The authors (𝗔) are the people who contributed to the feature (writing the implementation, the tests, or, for features that graduated from an earlier experiment, the original design), not necessarily a single main author.

Error handling is often skipped to keep the examples short. Don’t do this in production ツ

Generic methods

#

This is the headline of the release. A method declaration may now declare its own type parameters, independent of the receiver’s. Before Go 1.27, only top-level functions could be generic, so a generic operation on a type had to live as a package-level function instead of a method.

Say we have a generic container and want a Map operation that can change the element type:

type Box[T any] struct{ v T }

// The method declares its own type parameter U (new in Go 1.27).
func (b Box[T]) Map[U any](f func(T) U) Box[U] {
    return Box[U]{v: f(b.v)}
}

Now Map is a method of Box and can transform an int box into a string box:

func main() {
    b := Box[int]{v: 21}
    doubled := b.Map(func(n int) int { return n * 2 })
    label := doubled.Map(func(n int) string {
        return fmt.Sprintf("value=%d", n)
    })
    fmt.Println(label.v)
}

There is one important restriction: interfaces still can’t declare type-parameterized methods, and a generic method can’t be used to satisfy an interface. Put a generic method in an interface and the compiler stops you:

type Mapper interface {
    Map[U any](f func(int) U) any // interfaces can't declare generic methods
}
interface method must have no type parameters

Struct literal field selectors

#

A key in a struct literal may now be any valid field selector for the struct type, not just a top-level field name. In practice this means you can set a promoted field (one that comes from an embedded struct) directly, without spelling out the embedded type.

type Base struct {
    ID int
}

type User struct {
    Base
    Name string
}

Before Go 1.27 you had to write User{Base: Base{ID: 7}, Name: "Mittens"}. Now the promoted ID works as a key on its own:

u := User{ID: 7, Name: "Mittens"}
fmt.Println(u.ID, u.Name)

Generalized function type inference

#

Function type inference has been generalized to apply in all contexts where a generic function is used where a matching function type is expected: not just plain assignment to a variable (which already worked), but also conversions and composite literals. In those cases you previously had to spell out the type arguments by hand.

Take two generic helpers and drop them into a slice whose element type is func([]int) int:

func first[T any](s []T) T { return s[0] }
func last[T any](s []T) T  { return s[len(s)-1] }
// The slice's element type drives inference: T=int for each entry.
// Before Go 1.27 this failed with "cannot use generic function
// without instantiation"; you had to write first[int], last[int].
ops := []func([]int) int{first, last}
for _, op := range ops {
    fmt.Println(op([]int{10, 20, 30}))
}

Faster memory allocation

#

The compiler now generates calls to size-specialized memory allocation routines, cutting the cost of some small (under 80 bytes) allocations by up to 30%. Improvements vary with the workload, but the overall gain is expected to be around 1% in real allocation-heavy programs. The tradeoff is about 60 KB of extra binary size, independent of the workload.

There’s nothing to change in your code; it just gets a little faster. If you need to turn it off, build with GOEXPERIMENT=nosizespecializedmalloc. That opt-out is expected to be removed in Go 1.28.

Goroutine labels in tracebacks

#

For modules whose go.mod sets Go 1.27 or later, tracebacks now include runtime/pprof goroutine labels in the header line of each goroutine. If you already attach labels for profiling with pprof.Do, that context now shows up in crash dumps, SIGQUIT traces, and runtime.Stack output too (handy for telling apart otherwise identical goroutines).

Here we attach a label, then dump the current goroutine’s stack to see it in action:

ctx := context.Background()
pprof.Do(ctx, pprof.Labels("request", "42"), func(ctx context.Context) {
    buf := make([]byte, 1<<12)
    n := runtime.Stack(buf, false)
    fmt.Printf("%s", buf[:n])
})
goroutine 1 [running] {request: 42}:
main.main.func1(...)
	.../main.go:14 +0x38
runtime/pprof.Do(...)
	.../runtime/pprof/runtime.go:57 +0x8c
main.main()
	.../main.go:12 +0x6c

The pointer arguments, offsets, and file paths differ from run to run; what’s new is the {request: 42} appended right after the goroutine’s [running] state: its pprof labels. That same {...} annotation appears on the header of every labeled goroutine in a panic or SIGQUIT traceback. You can disable it with GODEBUG=tracebacklabels=0 (the setting was added in Go 1.26). The opt-out is expected to stay indefinitely, in case labels carry sensitive data you don’t want in tracebacks.

Goroutine leak profile

#

Go 1.26 introduced a goroutine leak detector as an experiment. In Go 1.27 it graduates to a regular profile: runtime/pprof exposes a goroutineleak profile that runs a GC cycle to find goroutines that are permanently blocked (leaked) and reports their stacks; no GOEXPERIMENT needed anymore.

A “leaked” goroutine is one blocked forever on a channel, mutex, or similar, with no way to ever make progress. The classic example is a goroutine that sends to a channel it alone holds, so nobody can ever receive from it:

func leak() {
    ch := make(chan int) // only this goroutine ever sees ch
    ch <- 1              // blocks forever: nobody will ever receive
}

Start one, let it park, then dump the profile:

go leak() // this goroutine can never finish

runtime.Gosched() // let it park on the send

// The GC-backed scan finds goroutines that can never make progress.
pprof.Lookup("goroutineleak").WriteTo(os.Stdout, 1)
goroutineleak profile: total 1
1 @ 0x... 0x... 0x... 0x... 0x...
#	0x...	main.leak+0x27	.../main.go:11

The total 1 line says the detector found exactly one leaked goroutine, and the stack pins it to main.leak: the ch <- 1 send that will never complete (the addresses vary from run to run). In a real service you’d usually scrape the /debug/pprof/goroutineleak net/http/pprof endpoint instead of writing to stdout.

Post-quantum signatures

#

The new crypto/mldsa package implements ML-DSA, the post-quantum digital signature scheme specified in FIPS 204. It comes in three parameter sets (MLDSA44, MLDSA65, and MLDSA87), trading key/signature size for security level.

priv, _ := mldsa.GenerateKey(mldsa.MLDSA65())

msg := []byte("victoria metrics")
sig, _ := priv.Sign(rand.Reader, msg, crypto.Hash(0))

fmt.Println("scheme:  ", mldsa.MLDSA65())
fmt.Println("sig size:", mldsa.MLDSA65().SignatureSize())
fmt.Println("verified:", mldsa.Verify(priv.PublicKey(), msg, sig, nil) == nil)
scheme:   ML-DSA-65
sig size: 3309
verified: true

ML-DSA support also reaches crypto/x509 (private keys, public keys, and signatures) and crypto/tls (the new MLDSA44, MLDSA65, and MLDSA87 signature schemes in TLS 1.3).

The uuid package

#

Go finally has a UUID package in the standard library. The new top-level uuid package generates and parses UUIDs per RFC 9562, using a cryptographically secure random source. Random-component UUIDs are comparable, so you can use == on them directly.

a := uuid.MustParse("f81d4fae-7dec-11d0-a765-00a0c91e6bf6")
fmt.Println("parsed:", a)
fmt.Println("nil:   ", uuid.Nil())
fmt.Println("max:   ", uuid.Max())
parsed: f81d4fae-7dec-11d0-a765-00a0c91e6bf6
nil:    00000000-0000-0000-0000-000000000000
max:    ffffffff-ffff-ffff-ffff-ffffffffffff

For generation, uuid.New() picks an algorithm suitable for most uses, while uuid.NewV4() gives a purely random UUID and uuid.NewV7() gives a time-ordered one; the latter is great for database keys because it sorts by creation time. Each call produces a fresh value, so try running this a few times:

fmt.Println(uuid.NewV4()) // random
fmt.Println(uuid.NewV7()) // time-ordered

JSON v2 by default

#

The long-awaited encoding/json/v2 rewrite has been experimental since Go 1.25. In Go 1.27 the experiment graduates: encoding/json/v2 and its low-level companion encoding/json/jsontext are now available without the GOEXPERIMENT=jsonv2 build flag. The quieter but bigger change: the classic encoding/json (v1) package is now backed by the v2 implementation under the hood.

The switch is transparent: behavior is preserved (only some error-message text differs), with new options pinning v2 to v1 semantics where they’d otherwise diverge. No migration is required, and GOEXPERIMENT=nojsonv2 restores the original v1 implementation if you hit a compatibility issue.

For the common case, the v2 API mirrors v1 (the import here is json "encoding/json/v2"):

type Point struct {
    X int `json:"x"`
    Y int `json:"y"`
}

data, err := json.Marshal(Point{X: 1, Y: 2})
fmt.Println(string(data), err)

One behavior worth knowing: unlike v1, which always sorts map keys, v2 does not sort them by default; skipping the sort is faster. When you need stable map output (for golden tests, say), pass the json.Deterministic option.

Portable SIMD

#

Go 1.27 adds an experimental simd package: portable, vector-size-agnostic SIMD that compiles down to real hardware vector instructions where they’re available and falls back to a pure-Go emulation where they aren’t. It’s off by default; you build with GOEXPERIMENT=simd to enable it.

The types are named after their element type with an s suffix (Int32s, Float32s, Float64s, and so on), and their width is deliberately not fixed: a Float32s might hold 4 lanes on one machine and 16 on another. You load a vector from a slice, operate on it, and store it back, letting the hardware pick the width:

a := []float32{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
b := []float32{10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160}

va := simd.LoadFloat32s(a) // reads exactly va.Len() lanes from a
vb := simd.LoadFloat32s(b)

sum := va.Add(vb) // element-wise add, many lanes in one instruction

out := make([]float32, sum.Len())
sum.Store(out)

fmt.Println(out[:4])

Cut around the last separator

#

strings.Cut (from Go 1.18) splits around the first occurrence of a separator. Go 1.27 adds strings.CutLast (and bytes.CutLast) for the last occurrence (a cleaner replacement for many LastIndex dances).

before, after, found := strings.CutLast("a/b/c", "/")
fmt.Printf("%q %q %v\n", before, after, found)

before, after, found = strings.CutLast("nosep", "/")
fmt.Printf("%q %q %v\n", before, after, found)
"a/b" "c" true
"nosep" "" false

As with Cut, when the separator isn’t found you get the whole input as before, an empty after, and found == false.

Generic hashing

#

The hash/maphash package gains a Hasher[T] interface: a contract that future hash-based data structures (hash tables, Bloom filters, and so on) can use to hash and compare values of a type. It bundles two operations: Hash, which mixes a value into a running hash, and Equal, which compares two values. The rule tying them together is that equal values must hash the same.

There’s a ready-made ComparableHasher[T] (hash by value, equality by ==) for any comparable type, but the interesting part is defining your own. Here’s a case-insensitive string hasher:

type ciHasher struct{}

// Equal ignores case; Hash mixes in the lower-cased form, so values
// that are Equal always hash the same.
func (ciHasher) Hash(h *maphash.Hash, s string) { h.WriteString(strings.ToLower(s)) }
func (ciHasher) Equal(x, y string) bool         { return strings.EqualFold(x, y) }

Now "Go" and "GO" count as equal and hash identically, which plain == and value hashing can’t do:

var h maphash.Hasher[string] = ciHasher{} // plug in the custom strategy

fmt.Println(h.Equal("Go", "GO"), h.Equal("Go", "Rust"))

// Equal values must hash the same, so feed each into a Hash sharing one seed:
seed := maphash.MakeSeed()
var a, b maphash.Hash
a.SetSeed(seed)
b.SetSeed(seed)
h.Hash(&a, "Go")
h.Hash(&b, "GO")
fmt.Println(a.Sum64() == b.Sum64())

Integer division with rounding

#

math/big adds Int.Divide, which computes a quotient and remainder together with an explicit rounding mode: Trunc, Floor, Round, or Ceil. The classic Quo/Mod always truncates toward zero, so this fills a real gap for financial and numeric code.

x, y := big.NewInt(7), big.NewInt(2)
q, r := new(big.Int), new(big.Int)

q.Divide(x, y, r, big.Ceil)
fmt.Printf("ceil:  q=%s r=%s\n", q, r)

q.Divide(x, y, r, big.Floor)
fmt.Printf("floor: q=%s r=%s\n", q, r)
ceil:  q=4 r=-1
floor: q=3 r=1

Notice how the remainder follows the rounding mode: with Ceil the quotient rounds up to 4, leaving a remainder of −1; with Floor it rounds down to 3, leaving 1.

Random numbers, your type

#

math/rand/v2 has had a top-level generic N function since Go 1.22. Go 1.27 adds it as a method, (*Rand).N, so you can draw a bounded random number of any integer or duration type from your own *Rand source.

r := rand.New(rand.NewPCG(1, 2)) // fixed seed → reproducible
fmt.Println(r.N(100))            // int in [0, 100)

Sleep in synthetic time

#

testing/synctest (stable since Go 1.25) lets you test concurrent code against a fake clock. Go 1.27 adds a Sleep helper that combines time.Sleep with synctest.Wait: advance the bubble’s synthetic clock and then wait for all goroutines to settle, in one call.

Inside a bubble the time package uses a fake clock, so a two-second sleep returns instantly; synctest.Sleep also waits for the background goroutine to finish before moving on:

t := &testing.T{} // in real code, use the *testing.T your test receives
synctest.Test(t, func(t *testing.T) {
    start := time.Now()
    go func() {
        time.Sleep(time.Second)
        fmt.Println("worker woke at", time.Since(start))
    }()

    // Advance fake time by 2s AND wait for goroutines to settle, in one call.
    synctest.Sleep(2 * time.Second)
    fmt.Println("main advanced", time.Since(start))
})
worker woke at 1s
main advanced 2s

Both durations are exact; no real time passes. It’s a small convenience, but it removes a common two-line boilerplate from almost every synctest-based test. (The bare &testing.T{} above is only to make the snippet self-contained; in a real test synctest.Sleep lives inside a func TestXxx(t *testing.T) and you pass that t.)

In-memory test servers

#

httptest.NewTestServer creates an httptest.Server backed by an in-memory fake network instead of a real TCP listener. No real ports are involved, and it registers its own cleanup via t.Cleanup, so there’s no defer srv.Close() to remember. It also pairs with testing/synctest, letting HTTP round-trips run in synthetic time for faster, fully deterministic tests.

t := &testing.T{} // in real code, use the *testing.T your test receives
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "hello from the in-memory server")
})

srv := httptest.NewTestServer(t, handler) // in-memory network, auto-cleanup
resp, _ := srv.Client().Get(srv.URL)      // no real TCP port
body, _ := io.ReadAll(resp.Body)
fmt.Print(string(body))
hello from the in-memory server

The request never touches the network stack; srv.Client() is wired straight to the handler over an in-process pipe. (As with the previous example, the bare &testing.T{} is only to keep the snippet self-contained; in a real test you’d pass the t from your func TestXxx(t *testing.T).)

Unicode 17

#

The unicode package and the rest of the standard library have been upgraded from Unicode 15 to Unicode 17, picking up new scripts, characters, and properties.

To see the jump in action, take 🫜 (U+1FADC “root vegetable”), which was added in Unicode 16.0. On Go’s old Unicode 15 data it was an unassigned code point, so IsSymbol and IsGraphic both returned false; now it’s a recognized symbol:

fmt.Println("Unicode", unicode.Version)

r := '🫜' // U+1FADC "root vegetable", added in Unicode 16.0
fmt.Printf("%#U  symbol=%v graphic=%v\n", r, unicode.IsSymbol(r), unicode.IsGraphic(r))
Unicode 17.0.0
U+1FADC '🫜'  symbol=true graphic=true

On Go 1.26 the very same code prints Unicode 15.0.0 and U+1FADC symbol=false graphic=false; the code point isn’t even printable, so %#U omits the glyph.