Go 1.24 interactive tour

Go 1.24 is scheduled for release in February, so it's a good time to explore what's new. The official release notes are pretty dry, so I prepared an interactive version with lots of examples showing what has changed and what the new behavior is.

Read on and see!

Generic aliases • Weak pointers • Improved finalizers • Swiss tables • Concurrent map • Directory scope • Benchmark loop • Synthetic time • Test context • Discard logs • Appender interfaces • More iterators • SHA-3 and friends • HTTP protocols • Omit zeros • Random text • Tool dependencies • JSON output • Main version • Summary

This article is based on the official release notes from The Go Authors, licensed under the BSD-3-Clause license. This is not an exhaustive list; see the official release notes for that.

I also provide links to the proposals (𝗣) and commits (𝗖𝗟) for the features described. Check them out for motivation and implementation details.

Generic type aliases

A quick refresher: type alias in Go creates a synonym for a type without creating a new type.

When a type is defined based on another type, the types are different:

type ID int

var n int = 10
var id ID = 10

// id = n
// Compile-time error:
// cannot use n (variable of type int) as ID value in assignment

id = ID(n)
fmt.Printf("id is %T\n", id)
id is main.ID

When a type is declared as an alias of another type, the types remain the same:

type ID = int

var n int = 10
var id ID = 10

id = n // works fine
fmt.Printf("id is %T\n", id)
id is int

Go 1.24 supports generic type aliases: a type alias can be parameterized like a defined type. For example, you can define Set as a generic alias to a map with boolean values (not that it helps much):

type Set[T comparable] = map[T]bool
set := Set[string]{"one": true, "two": true}

fmt.Println("'one' in set:", set["one"])
fmt.Println("'six' in set:", set["six"])
fmt.Printf("set is %T\n", set)
'one' in set: true
'six' in set: false
Set is map[string]bool

The language spec is updated accordingly. For now, you can disable the feature by setting GOEXPERIMENT=noaliastypeparams, but this option will be removed in Go 1.25.

𝗣 46477

Weak pointers

A weak pointer references an object like a regular pointer. But unlike a regular pointer, a weak pointer cannot keep an object alive. If only weak pointers reference an object, the garbage collector can reclaim its memory.

Let's say we have a blob type:

// Blob is a large byte slice.
type Blob []byte

func (b Blob) String() string {
    return fmt.Sprintf("Blob(%d KB)", len(b)/1024)
}

// newBlob returns a new Blob of the given size in KB.
func newBlob(size int) *Blob {
    b := make([]byte, size*1024)
    for i := range size {
        b[i] = byte(i) % 255
    }
    return (*Blob)(&b)
}

And a pointer to a 1024 KB blob:

func main() {
    b := newBlob(1000) // 1000 KB
    fmt.Println(b)
}
Blob(1000 KB)

We can create a weak pointer (weak.Pointer) from a regular one with weak.Make, and access the original pointer using the Pointer.Value method:

func main() {
    wb := weak.Make(newBlob(1000)) // 1000 KB
    fmt.Println(wb.Value())
}
Blob(1000 KB)

The regular pointer prevents the garbage collector from reclaiming the memory occupied by an object:

func main() {
    heapSize := getAlloc()
    b := newBlob(1000)

    fmt.Println("value before GC =", b)
    runtime.GC()
    fmt.Println("value after GC =", b)
    fmt.Printf("heap size delta = %d KB\n", heapDelta(heapSize))
}
value before GC = Blob(1000 KB)
value after GC = Blob(1000 KB)
heap size delta = 1002 KB
What are getAlloc and heapDelta
// heapDelta returns the delta in KB between
// the current heap size and the previous heap size.
func heapDelta(prev uint64) uint64 {
    cur := getAlloc()
    if cur < prev {
        return 0
    }
    return cur - prev
}

// getAlloc returns the current heap size in KB.
func getAlloc() uint64 {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    return m.Alloc / 1024
}

The weak pointer allows the garbage collector to free the memory:

func main() {
    heapSize := getAlloc()
    wb := weak.Make(newBlob(1000))

    fmt.Println("value before GC =", wb.Value())
    runtime.GC()
    fmt.Println("value after GC =", wb.Value())
    fmt.Printf("heap size delta = %d KB\n", heapDelta(heapSize))
}
value before GC = Blob(1000 KB)
value after GC = <nil>
heap size delta = 2 KB

As you can see, Pointer.Value returns nil if the original pointer's value was reclaimed by the garbage collector. Note that it's not guaranteed to return nil as soon as an object is no longer referenced (or at any time later); the runtime decides when to reclaim the memory.

Weak pointers are useful for implementing a cache of large objects, ensuring that an object isn't kept alive just because it's in the cache. See the next section for an example.

𝗣 67552 • 𝗖𝗟 628455

Improved finalizers

Remember our blob?

func main() {
    b := newBlob(1000)
    fmt.Printf("b=%v, type=%T\n", b, b)
}
b=Blob(1000 KB), type=*main.Blob

What if we want to run a cleanup function when the blob is garbage collected?

Previously, we'd call runtime.SetFinalizer, which is notoriously hard to use. Now there's a better solution with runtime.AddCleanup:

func main() {
    b := newBlob(1000)
    now := time.Now()
    // Register a cleanup function to run
    // when the object is no longer reachable.
    runtime.AddCleanup(b, cleanup, now)

    time.Sleep(10 * time.Millisecond)
    b = nil
    runtime.GC()
    time.Sleep(10 * time.Millisecond)
}

func cleanup(created time.Time) {
    fmt.Printf(
        "object is cleaned up! lifetime = %dms\n",
        time.Since(created)/time.Millisecond,
    )
}
object is cleaned up! lifetime = 10ms

AddCleanup attaches a cleanup function to an object that runs when the object is no longer reachable. The cleanup function runs in a separate goroutine, which handles all cleanup calls for a program sequentially. Multiple cleanups can be attached to the same pointer.

Note an argument to the cleanup function:

// AddCleanup attaches a cleanup function to ptr.
// Some time after ptr is no longer reachable,
// the runtime will call cleanup(arg) in a separate goroutine.
func AddCleanup[T, S any](ptr *T, cleanup func(S), arg S) Cleanup

In the example above, we passed the creation time as an argument, but typically it would be a resource we want to clean up when the pointer is garbage collected.

Here's an example. Suppose we want to implement a WeakMap, where an item can be discarded if no one references it's value. Let's use a map with weak.Pointer values:

// WeakMap is a map with weakly referenced values.
type WeakMap[K comparable, V any] struct {
    store map[K]weak.Pointer[V]
    mu    sync.Mutex
}

// NewWeakMap creates a new WeakMap.
func NewWeakMap[K comparable, V any]() *WeakMap[K, V] {
    return &WeakMap[K, V]{
        store: make(map[K]weak.Pointer[V]),
    }
}

// Len returns the number of items in the map.
func (wm *WeakMap[K, V]) Len() int {
    wm.mu.Lock()
    defer wm.mu.Unlock()
    return len(wm.store)
}

Getting a value is straightforward:

// Get returns the value stored in the map for a key,
// or nil if no value is present.
func (wm *WeakMap[K, V]) Get(key K) *V {
    wm.mu.Lock()
    defer wm.mu.Unlock()

    if wp, found := wm.store[key]; found {
        return wp.Value()
    }
    return nil
}

Now, how do we ensure the item is removed from the map when the runtime reclaims the value? With runtime.AddCleanup, it's simple:

// Set sets the value for a key.
func (wm *WeakMap[K, V]) Set(key K, value *V) {
    wm.mu.Lock()
    defer wm.mu.Unlock()

    // Create a weak pointer for the value.
    wp := weak.Make(value)

    // Remove the item when the value is reclaimed.
    runtime.AddCleanup(value, wm.Delete, key)

    // Store the weak pointer in the map.
    wm.store[key] = wp
}

// Delete removes an item for a key.
func (wm *WeakMap[K, V]) Delete(key K) {
    wm.mu.Lock()
    defer wm.mu.Unlock()
    delete(wm.store, key)
}

We pass the current key to the cleanup function (wm.Delete) so it knows which item to remove from the map.

var sink *Blob

func main() {
    wm := NewWeakMap[string, Blob]()
    wm.Set("one", newBlob(10))
    wm.Set("two", newBlob(20))

    fmt.Println("Before GC:")
    fmt.Println("len(map) =", wm.Len())
    fmt.Println("map[one] =", wm.Get("one"))
    fmt.Println("map[two] =", wm.Get("two"))

    // Allow the garbage collector to reclaim
    // the second item, but not the first one.
    sink = wm.Get("one")
    runtime.GC()

    fmt.Println("After GC:")
    fmt.Println("len(map) =", wm.Len())
    fmt.Println("map[one] =", wm.Get("one"))
    fmt.Println("map[two] =", wm.Get("two"))
}
len(map) =  2
map[one] = Blob(10 KB)
map[two] = Blob(20 KB)
len(map) =  1
map[one] = Blob(10 KB)
map[two] = <nil>

Works like a charm!

Note that the cleanup function is not guaranteed to run immediately after an object is no longer referenced; it may execute at an arbitrary time in the future.

With the introduction of AddCleanup, the usage of SetFinalizer is discouraged. New code should prefer AddCleanup.

𝗣 67535 • 𝗖𝗟 627695, 627975

Swiss tables

After many years, the Go team decided to change the underlying map implementation! It is now based on SwissTable, which offers several optimizations:

  • Access and assignment of large (>1024 entries) maps improved ~30%.
  • Assignment into pre-sized maps improved ~35%.
  • Iteration faster across the board by ~10%, ~60% for maps with low load (large size, few entries).
Benchmarks

These results are missing a few optimizations, but give a good overview of changes.

                                                          │ /tmp/noswiss.lu.txt │          /tmp/swiss.lu.txt           │
                                                          │       sec/op        │    sec/op      vs base               │
MapIter/impl=runtimeMap/t=Int64/len=64-12                          642.0n ±  3%    603.8n ±  6%   -5.95% (p=0.004 n=6)
MapIter/impl=runtimeMap/t=Int64/len=8192-12                        87.98µ ±  1%    78.80µ ±  1%  -10.43% (p=0.002 n=6)
MapIter/impl=runtimeMap/t=Int64/len=4194304-12                     47.40m ±  2%    44.41m ±  2%   -6.30% (p=0.002 n=6)
MapIterLowLoad/impl=runtimeMap/t=Int64/len=64-12                  145.85n ±  3%    92.85n ±  2%  -36.34% (p=0.002 n=6)
MapIterLowLoad/impl=runtimeMap/t=Int64/len=8192-12                13.205µ ±  0%    6.078µ ±  1%  -53.97% (p=0.002 n=6)
MapIterLowLoad/impl=runtimeMap/t=Int64/len=4194304-12              15.20m ±  1%    18.22m ±  1%  +19.87% (p=0.002 n=6)
MapIterGrow/impl=runtimeMap/t=Int64/len=64-12                     10.196µ ±  2%    8.092µ ±  8%  -20.63% (p=0.002 n=6)
MapIterGrow/impl=runtimeMap/t=Int64/len=8192-12                    1.259m ±  2%    1.008m ±  4%  -19.97% (p=0.002 n=6)
MapIterGrow/impl=runtimeMap/t=Int64/len=4194304-12                  1.424 ±  5%     1.275 ±  0%  -10.47% (p=0.002 n=6)
MapGetHit/impl=runtimeMap/t=Int64/len=64-12                        14.08n ±  4%    15.28n ±  3%   +8.45% (p=0.002 n=6)
MapGetHit/impl=runtimeMap/t=Int64/len=8192-12                      27.61n ±  1%    18.80n ±  1%  -31.89% (p=0.002 n=6)
MapGetHit/impl=runtimeMap/t=Int64/len=4194304-12                   82.94n ±  1%   102.20n ±  0%  +23.22% (p=0.002 n=6)
MapGetHit/impl=runtimeMap/t=Int32/len=64-12                        13.84n ±  5%    15.56n ±  2%  +12.39% (p=0.002 n=6)
MapGetHit/impl=runtimeMap/t=Int32/len=8192-12                      26.90n ±  2%    18.47n ±  2%  -31.34% (p=0.002 n=6)
MapGetHit/impl=runtimeMap/t=Int32/len=4194304-12                   79.60n ±  0%    93.00n ±  0%  +16.83% (p=0.002 n=6)
MapGetHit/impl=runtimeMap/t=String/len=64-12                       16.36n ±  6%    18.69n ±  1%  +14.24% (p=0.002 n=6)
MapGetHit/impl=runtimeMap/t=String/len=8192-12                     38.39n ±  1%    25.67n ±  1%  -33.13% (p=0.002 n=6)
MapGetHit/impl=runtimeMap/t=String/len=4194304-12                  146.0n ±  1%    172.2n ±  1%  +17.95% (p=0.002 n=6)
MapGetMiss/impl=runtimeMap/t=Int64/len=64-12                       15.63n ±  8%    15.08n ±  8%        ~ (p=0.240 n=6)
MapGetMiss/impl=runtimeMap/t=Int64/len=8192-12                     17.55n ±  1%    17.59n ±  4%        ~ (p=0.909 n=6)
MapGetMiss/impl=runtimeMap/t=Int64/len=4194304-12                 106.40n ±  1%    72.99n ±  2%  -31.40% (p=0.002 n=6)
MapGetMiss/impl=runtimeMap/t=Int32/len=64-12                       15.63n ±  7%    15.27n ±  8%        ~ (p=0.132 n=6)
MapGetMiss/impl=runtimeMap/t=Int32/len=8192-12                     17.18n ±  3%    17.25n ±  1%        ~ (p=0.729 n=6)
MapGetMiss/impl=runtimeMap/t=Int32/len=4194304-12                 100.15n ±  1%    74.71n ±  1%  -25.40% (p=0.002 n=6)
MapGetMiss/impl=runtimeMap/t=String/len=64-12                      18.96n ±  3%    18.19n ± 11%        ~ (p=0.132 n=6)
MapGetMiss/impl=runtimeMap/t=String/len=8192-12                    23.79n ±  3%    20.98n ±  2%  -11.79% (p=0.002 n=6)
MapGetMiss/impl=runtimeMap/t=String/len=4194304-12                134.85n ±  1%    84.82n ±  1%  -37.10% (p=0.002 n=6)
MapPutGrow/impl=runtimeMap/t=Int64/len=64-12                       5.886µ ±  3%    5.699µ ±  3%   -3.18% (p=0.015 n=6)
MapPutGrow/impl=runtimeMap/t=Int64/len=8192-12                     739.1µ ±  2%    816.0µ ±  4%  +10.41% (p=0.002 n=6)
MapPutGrow/impl=runtimeMap/t=Int64/len=4194304-12                  929.3m ±  1%    894.2m ±  5%        ~ (p=0.065 n=6)
MapPutGrow/impl=runtimeMap/t=Int32/len=64-12                       5.487µ ±  4%    5.326µ ±  2%   -2.93% (p=0.028 n=6)
MapPutGrow/impl=runtimeMap/t=Int32/len=8192-12                     681.6µ ±  2%    767.3µ ±  2%  +12.58% (p=0.002 n=6)
MapPutGrow/impl=runtimeMap/t=Int32/len=4194304-12                  831.9m ±  2%    802.9m ±  1%   -3.49% (p=0.002 n=6)
MapPutGrow/impl=runtimeMap/t=String/len=64-12                      7.607µ ±  2%    7.379µ ±  2%   -2.99% (p=0.002 n=6)
MapPutGrow/impl=runtimeMap/t=String/len=8192-12                    1.204m ±  4%    1.212m ±  4%        ~ (p=0.310 n=6)
MapPutGrow/impl=runtimeMap/t=String/len=4194304-12                  1.699 ±  2%     1.876 ±  1%  +10.37% (p=0.002 n=6)
MapPutPreAllocate/impl=runtimeMap/t=Int64/len=64-12                2.179µ ±  1%    1.428µ ±  5%  -34.47% (p=0.002 n=6)
MapPutPreAllocate/impl=runtimeMap/t=Int64/len=8192-12              277.6µ ±  2%    198.6µ ±  1%  -28.45% (p=0.002 n=6)
MapPutPreAllocate/impl=runtimeMap/t=Int64/len=4194304-12           389.7m ±  1%    518.2m ±  1%  +32.97% (p=0.002 n=6)
MapPutPreAllocate/impl=runtimeMap/t=Int32/len=64-12                1.784µ ±  2%    1.110µ ±  3%  -37.78% (p=0.002 n=6)
MapPutPreAllocate/impl=runtimeMap/t=Int32/len=8192-12              228.1µ ±  5%    151.4µ ±  4%  -33.62% (p=0.002 n=6)
MapPutPreAllocate/impl=runtimeMap/t=Int32/len=4194304-12           361.5m ±  1%    481.2m ±  1%  +33.10% (p=0.002 n=6)
MapPutPreAllocate/impl=runtimeMap/t=String/len=64-12               2.670µ ±  3%    2.167µ ±  3%  -18.81% (p=0.002 n=6)
MapPutPreAllocate/impl=runtimeMap/t=String/len=8192-12             380.1µ ±  2%    417.2µ ±  9%   +9.77% (p=0.015 n=6)
MapPutPreAllocate/impl=runtimeMap/t=String/len=4194304-12          493.1m ±  4%    718.1m ±  7%  +45.62% (p=0.002 n=6)
MapPutReuse/impl=runtimeMap/t=Int32/len=64-12                     1421.0n ±  3%    804.0n ±  5%  -43.42% (p=0.002 n=6)
MapPutReuse/impl=runtimeMap/t=Int32/len=8192-12                    192.4µ ±  1%    120.6µ ±  1%  -37.30% (p=0.002 n=6)
MapPutReuse/impl=runtimeMap/t=Int32/len=4194304-12                 364.0m ±  2%    473.0m ±  2%  +29.95% (p=0.002 n=6)
MapPutReuse/impl=runtimeMap/t=String/len=64-12                     1.602µ ±  4%    1.083µ ± 14%  -32.41% (p=0.002 n=6)
MapPutReuse/impl=runtimeMap/t=String/len=8192-12                   232.4µ ±  1%    165.7µ ±  2%  -28.68% (p=0.002 n=6)
MapPutReuse/impl=runtimeMap/t=String/len=4194304-12                440.4m ±  2%    672.5m ±  1%  +52.72% (p=0.002 n=6)
MapPutDelete/impl=runtimeMap/t=Int64/len=64-12                     34.25n ±  3%    37.76n ±  5%  +10.23% (p=0.002 n=6)
MapPutDelete/impl=runtimeMap/t=Int64/len=8192-12                   57.91n ±  2%    45.24n ±  2%  -21.89% (p=0.002 n=6)
MapPutDelete/impl=runtimeMap/t=Int64/len=4194304-12                170.5n ±  0%    222.0n ±  1%  +30.20% (p=0.002 n=6)
MapPutDelete/impl=runtimeMap/t=Int32/len=64-12                     34.06n ±  4%    37.87n ±  6%  +11.16% (p=0.002 n=6)
MapPutDelete/impl=runtimeMap/t=Int32/len=8192-12                   54.92n ±  1%    43.41n ±  2%  -20.96% (p=0.002 n=6)
MapPutDelete/impl=runtimeMap/t=Int32/len=4194304-12                153.4n ±  1%    178.3n ±  2%  +16.26% (p=0.002 n=6)
MapPutDelete/impl=runtimeMap/t=String/len=64-12                    42.11n ±  8%    48.48n ±  7%  +15.12% (p=0.002 n=6)
MapPutDelete/impl=runtimeMap/t=String/len=8192-12                  78.46n ±  1%    56.10n ±  2%  -28.50% (p=0.002 n=6)
MapPutDelete/impl=runtimeMap/t=String/len=4194304-12               204.6n ±  1%    261.4n ±  1%  +27.76% (p=0.002 n=6)

You can disable the new implementation by setting GOEXPERIMENT=noswissmap at build time.

𝗣 54766

Concurrent hash-trie map

The implementation of sync.Map has changed to a concurrent hash-trie, improving performance, especially for map modifications. Modifications of disjoint key sets are less likely to contend on larger maps, and no ramp-up time is needed to achieve low-contention loads.

The new implementation outperforms the old one on nearly every benchmark:

                                │     before      │                 after                 │
                                │     sec/op      │    sec/op      vs base                │
MapLoadMostlyHits                   7.870n ±   1%    8.415n ±  3%    +6.93% (p=0.002 n=6)
MapLoadMostlyMisses                 7.210n ±   1%    5.314n ±  2%   -26.28% (p=0.002 n=6)
MapLoadOrStoreBalanced             360.10n ±  18%    71.78n ±  2%   -80.07% (p=0.002 n=6)
MapLoadOrStoreUnique                707.2n ±  18%    135.2n ±  4%   -80.88% (p=0.002 n=6)
MapLoadOrStoreCollision             5.089n ± 201%    3.963n ±  1%   -22.11% (p=0.002 n=6)
MapLoadAndDeleteBalanced           17.045n ±  64%    5.280n ±  1%   -69.02% (p=0.002 n=6)
MapLoadAndDeleteUnique             14.250n ±  57%    6.452n ±  1%         ~ (p=0.368 n=6)
MapLoadAndDeleteCollision           19.34n ±  39%    23.31n ± 27%         ~ (p=0.180 n=6)
MapRange                            3.055µ ±   3%    1.918µ ±  2%   -37.23% (p=0.002 n=6)
MapAdversarialAlloc                245.30n ±   6%    14.90n ± 23%   -93.92% (p=0.002 n=6)
MapAdversarialDelete              143.550n ±   2%    8.184n ±  1%   -94.30% (p=0.002 n=6)
MapDeleteCollision                  9.199n ±  65%    3.165n ±  1%   -65.59% (p=0.002 n=6)
MapSwapCollision                    164.7n ±   7%    108.7n ± 36%   -34.01% (p=0.002 n=6)
MapSwapMostlyHits                   33.12n ±  15%    35.79n ±  9%         ~ (p=0.180 n=6)
MapSwapMostlyMisses                 604.5n ±   5%    280.2n ±  7%   -53.64% (p=0.002 n=6)
MapCompareAndSwapCollision          96.02n ±  40%    69.93n ± 24%   -27.17% (p=0.041 n=6)
MapCompareAndSwapNoExistingKey      6.345n ±   1%    6.202n ±  1%    -2.24% (p=0.002 n=6)
MapCompareAndSwapValueNotEqual      6.121n ±   3%    5.564n ±  4%    -9.09% (p=0.002 n=6)
MapCompareAndSwapMostlyHits         44.21n ±  13%    43.46n ± 11%         ~ (p=0.485 n=6)
MapCompareAndSwapMostlyMisses       33.51n ±   6%    13.51n ±  5%   -59.70% (p=0.002 n=6)
MapCompareAndDeleteCollision        27.85n ± 104%    31.02n ± 26%         ~ (p=0.180 n=6)
MapCompareAndDeleteMostlyHits       50.43n ±  33%   109.45n ±  8%  +117.03% (p=0.002 n=6)
MapCompareAndDeleteMostlyMisses     27.17n ±   7%    11.37n ±  3%   -58.14% (p=0.002 n=6)
MapClear                            300.2n ±   5%    124.2n ±  8%   -58.64% (p=0.002 n=6)
geomean                             50.38n           25.79n         -48.81%

The load-hit case (MapLoadMostlyHits) is slightly slower due to Swiss Tables improving the performance of the old sync.Map. Some benchmarks show a seemingly large slowdown, but that's mainly due to the fact that the new implementation can actually shrink, whereas the old one never shrank. This creates additional allocations.

The concurrent hash-trie map (HashTrieMap) was initially added for the unique package in Go 1.23. It proved faster than the original sync.Map in many cases, so the Go team reimplemented sync.Map as a wrapper for HashTrieMap.

You can disable the new implementation by setting GOEXPERIMENT=nosynchashtriemap at build time.

𝗣 70683 • 𝗖𝗟 608335

Directory-scoped filesystem access

The new os.Root type restricts filesystem operations to a specific directory.

The OpenRoot function opens a directory and returns a Root:

dir, err := os.OpenRoot("data")
fmt.Printf("opened root=%s, err=%v\n", dir.Name(), err)

Methods on Root operate within the directory and do not allow paths outside the directory:

file, err := dir.Open("01.txt")
fmt.Printf("opened file=%s, err=%v\n", file.Name(), err)

file, err = dir.Open("../main.txt")
fmt.Printf("opened file=%v, err=%v\n", file, err)
opened root=data, err=<nil>
opened file=data/01.txt, err=<nil>
opened file=<nil>, err=openat ../main.txt: path escapes from parent

Methods on Root mirror most file system operations available in the os package:

file, err := dir.Create("new.txt")
fmt.Printf("created file=%s, err=%v\n", file.Name(), err)

stat, err := dir.Stat("02.txt")
fmt.Printf(
    "file info: name=%s, size=%dB, mode=%v, err=%v\n",
    stat.Name(), stat.Size(), stat.Mode(), err,
)

err = dir.Remove("03.txt")
fmt.Printf("deleted 03.txt, err=%v\n", err)
opened root=data, err=<nil>
created file=data/new.txt, err=<nil>
file info: name=02.txt, size=69B, mode=-rw-r--r--, err=<nil>
deleted 03.txt, err=<nil>

You should close the Root after you are done with it:

func process(dir string) error {
    r, err := os.OpenRoot(dir)
    if err != nil {
        return err
    }
    defer r.Close()
    // do stuff
    return nil
}

After the Root is closed, calling its methods return errors:

err = dir.Close()
fmt.Printf("closed root, err=%v\n", err)

file, err := dir.Open("01.txt")
fmt.Printf("opened file=%v, err=%v\n", file, err)
opened root=data, err=<nil>
closed root, err=<nil>
opened file=<nil>, err=openat 01.txt: file already closed

Root methods follow symbolic links, but these links cannot reference locations outside the root. Symbolic links must be relative. Methods do not restrict traversal of filesystem boundaries, Linux bind mounts, /proc special files, or access to Unix device files.

On most platforms, creating a Root opens a file descriptor or handle for the directory. If the directory is moved, Root methods reference the directory in its new location.

𝗣 67002 • 𝗖𝗟 612136, 627076, 627475, 629518, 629555

Benchmark loop

You are probably familiar with a benchmark loop (for range b.N):

var sink int

func BenchmarkSlicesMax(b *testing.B) {
    // Setup the benchmark.
    s := randomSlice(10_000)
    b.ResetTimer()

    // Run the benchmark.
    for range b.N {
        sink = slices.Max(s)
    }
}
BenchmarkSlicesMax    206500    5522 ns/op

Go conveniently handles the mechanics of running benchmarks, determines a reasonable b.N, and provides the final results in nanoseconds per operation.

Yet, there are a few nuances to keep in mind:

  • The benchmark function (BenchmarkSlicesMax) runs multiple times, so the setup step also runs multiple times (nothing we can do about that).
  • We need to reset the benchmark timer to exclude the setup time from the benchmark time.
  • We have to ensure the compiler doesn't optimize away the benchmarked call (slices.Max) by using a sink variable.

Go 1.24 introduces the faster and less error-prone testing.B.Loop to replace the traditional for range b.N loop:

func BenchmarkSlicesMax(b *testing.B) {
    // Setup the benchmark.
    s := randomSlice(10_000)

    // Run the benchmark.
    for b.Loop() {
        slices.Max(s)
    }
}
BenchmarkSlicesMax    207292    5519 ns/op

b.Loop solves issues with the b.N method:

  • The benchmark function executes once per -count, so setup and cleanup steps run only once.
  • Everything outside the b.Loop doesn't affect the benchmark time, so b.ResetTimer isn't needed.
  • Compiler never optimizes away calls to functions within the body of a b.Loop.

Benchmarks should use either b.Loop or a b.N-style loop, but not both.

𝗣 61515 • 𝗖𝗟 608798, 612043, 612835, 627755, 635898

Synthetic time for testing

Suppose we have a function that waits for a value from a channel for one minute, then times out:

// Read reads a value from the input channel and returns it.
// Timeouts after 60 seconds.
func Read(in chan int) (int, error) {
    select {
    case v := <-in:
        return v, nil
    case <-time.After(60 * time.Second):
        return 0, errors.New("timeout")
    }
}

We use it like this:

func main() {
    ch := make(chan int)
    go func() {
        ch <- 42
    }()
    val, err := Read(ch)
    fmt.Printf("val=%v, err=%v\n", val, err)
}
val=42, err=<nil>

How do we test the timeout situation? Surely we don't want the test to actually wait 60 seconds. We could make the timeout a parameter (we probably should), but let's say we prefer not to.

The new testing/synctest package to the rescue! The synctest.Run() function executes an isolated "bubble" in a new goroutine. Within the bubble, time package functions use a fake clock, allowing our test to pass instantly:

func TestReadTimeout(t *testing.T) {
    synctest.Run(func() {
        ch := make(chan int)
        _, err := Read(ch)
        if err == nil {
            t.Fatal("expected timeout error, got nil")
        }
    })
}
PASS

Goroutines in the bubble use a synthetic time implementation (the initial time is midnight UTC 2000-01-01). Time advances when every goroutine in the bubble is blocked. In our example, when the only goroutine is blocked on select in Read, the bubble's clock advances 60 seconds, triggering the timeout case.

Another useful function is synctest.Wait. It waits for all goroutines in the current bubble to block, then resumes execution:

synctest.Run(func() {
    const timeout = 5 * time.Second
    ctx, cancel := context.WithTimeout(context.Background(), timeout)
    defer cancel()

    // Wait just less than the timeout.
    time.Sleep(timeout - time.Nanosecond)
    synctest.Wait()
    fmt.Printf("before timeout: ctx.Err() = %v\n", ctx.Err())

    // Wait the rest of the way until the timeout.
    time.Sleep(time.Nanosecond)
    synctest.Wait()
    fmt.Printf("after timeout:  ctx.Err() = %v\n", ctx.Err())
})
before timeout: ctx.Err() = <nil>
after timeout:  ctx.Err() = context deadline exceeded

The synctest package is experimental and must be enabled by setting GOEXPERIMENT=synctest at build time. The package API is subject to change in future releases. See the proposal for more information and to provide feeback.

𝗣 67434 • 𝗖𝗟 629735, 629856

Test context and working directory

Suppose we want to test this very useful server:

// Server provides answers to all questions.
type Server struct{}

// Get returns an answer from the server.
func (s *Server) Get(query string) int {
    return 42
}

// startServer starts a server that can
// be stopped by canceling the context.
func startServer(ctx context.Context) *Server {
    go func() {
        select {
        case <-ctx.Done():
            // Free resources.
        }
    }()
    return &Server{}
}

Here's a beautiful test I wrote:

func Test(t *testing.T) {
    srv := startServer(context.Background())
    if srv.Get("how much?") != 42 {
        t.Fatal("unexpected value")
    }
}
PASS

Hooray, the test passed! However, there's a problem: I used an empty context, so the server didn't actually stop. Such resource leakage can be an issue, especially with many tests.

I can fix it by creating a cancelable context and canceling it when the test completes:

func Test(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    srv := startServer(ctx)
    if srv.Get("how much?") != 42 {
        t.Fatal("unexpected value")
    }
}
PASS

Even better, now I can use the new T.Context method. It returns a context that is canceled after the test completes:

func Test(t *testing.T) {
    srv := startServer(t.Context())
    if srv.Get("how much?") != 42 {
        t.Fatal("unexpected value")
    }
}
PASS

One caveat remains. What if cleaning up server resources takes time? The startServer goroutine will initiate the cleanup when the test context closes, but will it finish before the main goroutine exits? Not necessarily.

There is a useful test context property that can help us. The test context is canceled just before T.Cleanup-registered functions are called. So we can use T.Cleanup to register a function that waits for the server to stop:

func Test(t *testing.T) {
    srv := startServer(t.Context())
    t.Cleanup(func() {
        <-srv.Done()
    })
    if srv.Get("how much?") != 42 {
        t.Fatal("unexpected value")
    }
}
server stopped
PASS
The server code has also changed
// Server provides answers to all questions.
type Server struct {
    done chan struct{}
}

// Get returns an anser from the server.
func (s *Server) Get(query string) int {
    return 42
}

// Stop stops the server.
func (s *Server) Stop() {
    // Simulate a long operation.
    time.Sleep(10 * time.Millisecond)
    fmt.Println("server stopped")
    close(s.done)
}

// Done returns a channel that's closed when the server stops.
func (s *Server) Done() <-chan struct{} {
    return s.done
}

// startServer starts a server that can
// be stopped by canceling the context.
func startServer(ctx context.Context) *Server {
    srv := &Server{done: make(chan struct{})}
    go func() {
        select {
        case <-ctx.Done():
            srv.Stop()
        }
    }()
    return srv
}

Like tests, benchmarks have their own B.Context.

𝗣 36532 • 𝗖𝗟 603959, 637236

Oh, and speaking of tests, the new T.Chdir and B.Chdir methods change the working directory for the duration of a test or benchmark:

func Test(t *testing.T) {
    t.Run("test1", func(t *testing.T) {
        // Change the working directory for the current test.
        t.Chdir("/tmp")
        cwd, _ := os.Getwd()
        if cwd != "/tmp" {
            t.Fatalf("unexpected cwd: %s", cwd)
        }
    })
    t.Run("test2", func(t *testing.T) {
        // This test uses the original working directory.
        cwd, _ := os.Getwd()
        if cwd == "/tmp" {
            t.Fatalf("unexpected cwd: %s", cwd)
        }
    })
}
PASS

Chdir methods use Cleanup to restore the working directory to its original value after the test or benchmark.

𝗣 62516 • 𝗖𝗟 529895

Discard log output

An easy way to create a silent logger (e.g. for testing or benchmarking) is to use slog.TextHandler with io.Discard:

log := slog.New(
    slog.NewTextHandler(io.Discard, nil),
)
log.Info("Prints nothing")
ok

Now there's an even easier way with the slog.DiscardHandler package-level variable:

log := slog.New(slog.DiscardHandler)
log.Info("Prints nothing")
ok

𝗣 62005 • 𝗖𝗟 626486

Appender interfaces

Two new interfaces, encoding.TextAppender and encoding.BinaryAppender, allow appending an object's textual or binary representation to a byte slice.

type TextAppender interface {
    // AppendText appends the textual representation of itself to the end of b
    // (allocating a larger slice if necessary) and returns the updated slice.
    //
    // Implementations must not retain b, nor mutate any bytes within b[:len(b)].
    AppendText(b []byte) ([]byte, error)
}
type BinaryAppender interface {
    // AppendBinary appends the binary representation of itself to the end of b
    // (allocating a larger slice if necessary) and returns the updated slice.
    //
    // Implementations must not retain b, nor mutate any bytes within b[:len(b)].
    AppendBinary(b []byte) ([]byte, error)
}

These interfaces provide the same functionality as TextMarshaler and BinaryMarshaler, but instead of allocating a new slice each time, they append the data directly to an existing slice.

These interfaces are now implemented by standard library types that already implemented TextMarshaler or BinaryMarshaler: math/big.Float, net.IP, regexp.Regexp, time.Time, and others:

// 2021-02-03T04:05:06Z
t := time.Date(2021, 2, 3, 4, 5, 6, 0, time.UTC)

var b []byte
b, err := t.AppendText(b)
fmt.Printf("b=%s, err=%v", b, err)
b=2021-02-03T04:05:06Z, err=<nil>

𝗣 62384 • 𝗖𝗟 601595, 601776, 603255, 603815, 605056, 605758, 606655, 607079, 607520, 634515

More string and byte iterators

Go 1.23 went all in on iterators, so we see more and more of them in the standard library.

New functions in the strings package:

Lines returns an iterator over the newline-terminated lines in the string s:

s := "one\ntwo\nsix"
for line := range strings.Lines(s) {
    fmt.Print(line)
}
one
two
six

SplitSeq returns an iterator over all substrings of s separated by sep:

s := "one-two-six"
for part := range strings.SplitSeq(s, "-") {
    fmt.Println(part)
}
one
two
six

SplitAfterSeq returns an iterator over substrings of s split after each instance of sep:

s := "one-two-six"
for part := range strings.SplitAfterSeq(s, "-") {
    fmt.Println(part)
}
one-
two-
six

FieldsSeq returns an iterator over substrings of s split around runs of whitespace characters, as defined by unicode.IsSpace:

s := "one two\nsix"
for part := range strings.FieldsSeq(s) {
    fmt.Println(part)
}
one
two
six

FieldsFuncSeq returns an iterator over substrings of s split around runs of Unicode code points satisfying f(c):

f := func(c rune) bool {
    return !unicode.IsLetter(c) && !unicode.IsNumber(c)
}

s := "one,two;six..."
for part := range strings.FieldsFuncSeq(s, f) {
    fmt.Println(part)
}
one
two
six

The same iterator functions were added to the bytes package.

𝗣 61901 • 𝗖𝗟 587095

SHA-3 and friends

The new crypto/sha3 package implements the SHA-3 hash function and SHAKE and cSHAKE extendable-output functions, as defined in FIPS 202:

s := []byte("go is awesome")
fmt.Printf("Source: %s\n", s)
fmt.Printf("SHA3-224: %x\n", sha3.Sum224(s))
fmt.Printf("SHA3-256: %x\n", sha3.Sum256(s))
fmt.Printf("SHA3-384: %x\n", sha3.Sum384(s))
fmt.Printf("SHA3-512: %x\n", sha3.Sum512(s))
Source: go is awesome
SHA3-224: 6df692e4c2c2105797ef341470b8489d973eaad9a481a68c34b7394a
SHA3-256: ece154f14fb7b5b0c8587acf30f749221ce15a8b0b3b174a41b8fc6dd004e5c4
SHA3-384: c7bef60a97267ae50c61b1045334a833e2d75dc54905e17991569aff70d41a8dac1e90ae3c68569656586058166ce47c
SHA3-512: 9d4dfcf250c5fb4c03d2234eb110b837d69e7146b3d20b0ba36cf54b131dead0ba675e7b13a3b89ba6430cb674dca40fe3357b34e3f4a54f7fcf93a789139c5e

𝗣 69982 • 𝗖𝗟 629176

And two more crypto packages:

crypto/hkdf package implements the HMAC-based Extract-and-Expand key derivation function HKDF, as defined in RFC 5869.

𝗣 61477 • 𝗖𝗟 630296

crypto/pbkdf2 package implements the password-based key derivation function PBKDF2, as defined in RFC 8018.

𝗣 69488 • 𝗖𝗟 628135

HTTP protocols

The new Server.Protocols and Transport.Protocols fields in the net/http package provide a simple way to configure what HTTP protocols a server or client use:

t := http.DefaultTransport.(*http.Transport).Clone()

// Use either HTTP/1 or HTTP/2.
t.Protocols = new(http.Protocols)
t.Protocols.SetHTTP1(true)
t.Protocols.SetHTTP2(true)

cli := &http.Client{Transport: t}
res, err := cli.Get("http://httpbingo.org/status/200")
if err != nil {
    panic(err)
}
res.Body.Close()
ok

The supported protocols are:

  • HTTP1 is the HTTP/1.0 and HTTP/1.1 protocols. HTTP1 is supported on both unsecured TCP and secured TLS connections.
  • HTTP2 is the HTTP/2 protcol over a TLS connection.
  • UnencryptedHTTP2 is the HTTP/2 protocol over an unsecured TCP connection.

𝗣 67814 • 𝗖𝗟 607496

Omit zero values in JSON

The new omitzero option in the field tag instructs the JSON marshaler to omit zero values. It is clearer and less error-prone than omitempty when the intent is to omit zero values. Unlike omitempty, omitzero omits zero-valued time.Time values, a common source of friction.

Compare omitempty:

type Person struct {
    Name      string    `json:"name"`
    BirthDate time.Time `json:"birth_date,omitempty"`
}

alice := Person{Name: "Alice"}
b, err := json.Marshal(alice)
fmt.Println(string(b), err)
{"name":"Alice","birth_date":"0001-01-01T00:00:00Z"} <nil>

To omitzero:

type Person struct {
    Name      string    `json:"name"`
    BirthDate time.Time `json:"birth_date,omitzero"`
}

alice := Person{Name: "Alice"}
b, err := json.Marshal(alice)
fmt.Println(string(b), err)
{"name":"Alice"} <nil>

If the field type has an IsZero() bool method, it will determine whether the value is zero. Otherwise, the value is zero if it is the zero value for its type.

𝗣 45669 • 𝗖𝗟 615676

Random text

The crypto/rand.Text function returns a cryptographically random string using the standard Base32 alphabet:

text := rand.Text()
fmt.Println(text)
4PJOOV7PVL3HTPQCD5Z3IYS5TC

The result contains at least 128 bits of randomness, enough to prevent brute force guessing attacks and to make the likelihood of collisions vanishingly small.

𝗣 67057 • 𝗖𝗟 627477

Tool dependencies

Go modules can now track executable dependencies using tool directives in go.mod.

To add a tool dependency, use go get -tool:

go mod init sandbox
go get -tool golang.org/x/tools/cmd/stringer

This adds a tool dependency with a require directive to the go.mod:

module sandbox

go 1.24rc1

tool golang.org/x/tools/cmd/stringer

require (
    golang.org/x/mod v0.22.0 // indirect
    golang.org/x/sync v0.10.0 // indirect
    golang.org/x/tools v0.29.0 // indirect
)

Tool dependencies remove the need for the previous workaround of adding tools as blank imports to a file conventionally named "tools.go". The go tool command can now run these tools in addition to tools shipped with the Go distribution:

go tool stringer

Refer to the documentation for details.

𝗣 48429

JSON output for build, install and test

The go build, go install, and go test commands now accept a -json flag that reports build output and failures as structured JSON on standard output.

For example, here is the go test output in default verbose mode:

go test -v
=== RUN   TestSet_Add
--- PASS: TestSet_Add (0.00s)
=== RUN   TestSet_Contains
--- PASS: TestSet_Contains (0.00s)
PASS
ok      sandbox 0.934s

And here is the go test output for the same program in JSON mode:

go test -json
{"Time":"2025-01-11T19:22:29.280091+05:00","Action":"start","Package":"sandbox"}
{"Time":"2025-01-11T19:22:29.671331+05:00","Action":"run","Package":"sandbox","Test":"TestSet_Add"}
{"Time":"2025-01-11T19:22:29.671418+05:00","Action":"output","Package":"sandbox","Test":"TestSet_Add","Output":"=== RUN   TestSet_Add\n"}
{"Time":"2025-01-11T19:22:29.67156+05:00","Action":"output","Package":"sandbox","Test":"TestSet_Add","Output":"--- PASS: TestSet_Add (0.00s)\n"}
{"Time":"2025-01-11T19:22:29.671579+05:00","Action":"pass","Package":"sandbox","Test":"TestSet_Add","Elapsed":0}
{"Time":"2025-01-11T19:22:29.671601+05:00","Action":"run","Package":"sandbox","Test":"TestSet_Contains"}
{"Time":"2025-01-11T19:22:29.671608+05:00","Action":"output","Package":"sandbox","Test":"TestSet_Contains","Output":"=== RUN   TestSet_Contains\n"}
{"Time":"2025-01-11T19:22:29.67163+05:00","Action":"output","Package":"sandbox","Test":"TestSet_Contains","Output":"--- PASS: TestSet_Contains (0.00s)\n"}
{"Time":"2025-01-11T19:22:29.671638+05:00","Action":"pass","Package":"sandbox","Test":"TestSet_Contains","Elapsed":0}
{"Time":"2025-01-11T19:22:29.671645+05:00","Action":"output","Package":"sandbox","Output":"PASS\n"}
{"Time":"2025-01-11T19:22:29.672058+05:00","Action":"output","Package":"sandbox","Output":"ok  \tsandbox\t0.392s\n"}
{"Time":"2025-01-11T19:22:29.6721+05:00","Action":"pass","Package":"sandbox","Elapsed":0.392}

There may also be non-JSON error text on standard error, even with the -json flag. Typically, this indicates an early, serious error.

For details of the JSON format, see go help buildjson.

𝗣 62067

Main module's version

The go build command now sets the main module's version (BuildInfo.Main.Version) in the compiled binary based on the version control system tag or commit. A +dirty suffix is added if there are uncommitted changes.

Here's a program that prints the version:

// get build information embedded in the running binary
info, _ := debug.ReadBuildInfo()
fmt.Println("Go version:", info.GoVersion)
fmt.Println("App version:", info.Main.Version)

Here's the output for Go 1.23:

Go version: go1.23.4
App version: (devel)

Here's the output for Go 1.24:

Go version: go1.24rc1
App version: v0.0.0-20250111143208-a7857c757b85+dirty

When the current commit matches a tagged version, the value is set to v<tag>[+dirty]:

v1.2.4
v1.2.4+dirty

When the current commit doesn't match a tagged version, the value is set to <pseudo>[+dirty], where pseudo consists of the latest tag, current date, and commit:

v1.2.3-0.20240620130020-daa7c0413123
v1.2.3-0.20240620130020-daa7c0413123+dirty

When no VCS information is available, the value is set to (devel) (as in Go 1.23).

Use the -buildvcs=false flag to omit version control information from the binary.

𝗣 50603

Summary

Go 1.24 introduces many new features, including weak pointers, finalizers, and directory-scoped filesystem access. A lot of effort has gone into implementing faster maps, which is a very welcome change. Also, the Go team clearly prioritizes the developer experience, offering easier and safer ways to write benchmarks, test concurrent code, and use custom tools. And of course, cryptographic improvements like SHA-3 and random text generation are a nice touch.

All in all, a great release!

──

P.S. To catch up on other Go releases, check out the Go features by version list or explore the interactive tours for Go 1.23 and 1.22.

P.P.S. Interactive examples in this post are powered by codapi — an open source tool I'm building. Use it to embed live code snippets into your product docs, online course or blog.

★ Subscribe to keep up with new posts.