

Disclaimer: This post includes Amazon affiliate links. Clicking on them earns me a commission and does not affect the final price.
Hello ๐ It’s that time of the year again! Another minor version of Go was released, superseding Go 1.23, released a few weeks ago on February 11th, 2025; this is Go 1.24! ๐ฅ ๐ ๐
๐งจ Go 1.24.0 is released!
— Go (@golang) February 11, 2025
๐ Release notes: https://t.co/EGh8fHpeuo
โฌ๏ธ Download: https://t.co/JO0ETD0App#golang pic.twitter.com/mTPTq4YVwz
Go 1.24 was released last week, and most of the popular operating systems and package managers already have a way to upgrade to Go 1.24; so packages for Windows, Linux, and MacOS (including Homebrew!) already have updates available; and also the corresponding Docker images for Debian and Alpine:
brew upgrade go
docker pull golang:1.24-bullseye
docker pull golang:1.24.0-alpine3.21
However, keep in mind that thanks to the Go toolchain, starting with 1.21, updating the go.mod
to 1.24 is more than enough to start using it:
go mod edit -go=1.24.0
Most of the changes in Go 1.24 are related to the toolchain, runtime, and libraries; new features include the go tool
command, the os.Root
type as well as the omitzero
struct field option; but there’s more than that! Please review the blog post and the official release notes for more details.
Let’s talk about some of the new features that were added in Go 1.24.
go tool
commandCode for this example is available on Github.
The new tool
command replaces the workaround conventionally known as tools.go
, where we blank import tools to track executable dependencies. Using this new tool
command simplifies the process and allows the toolchain to handle those dependencies.
In order to start using it, we need to first, get -tool
the tool, for example if I want to keep track of golangci/golangci-lint/cmd/golangci-lint@v1.64.3
I’d do:
go get -tool github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.3
This will update the go.mod
, adding a new section:
5tool (
6 github.com/MarioCarrion/videos/2025/go-1-24-0/01-go-tool/one
7 github.com/golangci/golangci-lint/cmd/golangci-lint
8)
And then, we can start using it via go tool <toolname>
, for example:
go tool golangci-lint version
Because the tool is tracked in the go.mod
we avoid having conflicts with other modules that happen to use the same tool but a different version, this also means two things:
direnv
are no longer needed, and there’s no need to go install
those tools either.However, keep in mind that this is a new feature, and if you use tools that keep track of your dependencies, such as Dependabot or Renovate, you should wait a bit until they support this new Go feature. Also if you decide to migrate to go tool
from the tools.go
paradigm, you may need to change your continuous integration steps to use the new runtime instead.
Code for this example is available on Github.
There’s a new type in the os
package called Root
that provides the ability to perform filesystem operations within a specific directory; this allows sandboxing that directory and preventing access to files outside this parent directory. It’s similar to the other os
functions that let us interact with files:
Root.Open
and Root.OpenFile
,Root.Create
,Root.Remove
,Root.Mkdir
,To start using it we need to call os.Root
passing in a path, for example:
12 root, err := os.OpenRoot(".")
13 if err != nil {
14 log.Fatalln("Couldn't open root:", err)
15 }
This let us interact with the Root
type and its methods, for example to make a new directory:
17 dirname := "test"
18
19 if err := root.Mkdir(dirname, 0750); err != nil { // similar to `os.Mkdir`
20 log.Fatalln("Couldn't make dir:", err)
21 }
Or to open a file to write into it:
17 //- Writing
18
19 filename := path.Join(dirname, "example.txt")
20
21 fwrite, err := root.OpenFile(filename, os.O_RDWR|os.O_CREATE, 0644) // similar to `os.OpenFile`
22 if err != nil {
23 log.Fatalln("Couldn't open file for writing:", err)
24 }
This new type must be used when dealing with file interactions in any new program in Go.
encoding/json
omitzeroCode for this example is available on Github.
omitzero
is a new struct field option used to omit the field when marshaling when if value is zero. It improves the existing omitempty
option by giving us flexibility to determine “when a value is zero”. This is done via a new IsZero()
method that the type can implement to determine the logic to use; and if it returns true
, then it will be omitted.
For example, take the following struct:
9type Example struct {
10 String string `json:"string,omitempty"`
11 KeyValue KeyValue `json:"keyValue,omitzero"`
12 OmitKeyValue KeyValue `json:"omitKeyValue,omitzero"`
13 ZeroValue1 ZeroValue `json:"zeroValue1,omitzero,omitempty"`
14 ZeroValue2 ZeroValue `json:"zeroValue2,omitempty"`
15}
Where the fields using omitzero
are:
KeyValue
,OmitKeyValue
, andZeroValue1
.The types KeyValue
and ZeroValue
implement IsZero
to define custom logic to indicate when the value is zero or not:
17type KeyValue struct {
18 Key string
19 Value string
20}
21
22func (z KeyValue) IsZero() bool {
23 return z.Key == "" && z.Value == ""
24}
25
26type ZeroValue int64
27
28func (z ZeroValue) IsZero() bool {
29 return int64(z) < 10
30}
If we use the following instance of Example
:
example := Example{
KeyValue: KeyValue{
Key: "key",
Value: "value",
},
ZeroValue1: 5, // Not marshaled values IsZero returns true
ZeroValue2: 0, // Not marshaled because it's an actual _zero value_
}
The expected JSON would be:
{
"keyValue": {
"Key": "key",
"Value": "value"
}
}
This is because only the field KeyValue
satisfies the logic implemented in IsZero
; if we change the example to:
example := Example{
KeyValue: KeyValue{
Key: "key",
Value: "value",
},
ZeroValue1: 11, // NOW marshaled because IsZero returns false
ZeroValue2: 0, // Not marshaled because it's an actual _zero value_
}
The output will change:
{
"keyValue": {
"Key": "key",
"Value": "value"
},
"zeroValue1": 11
}
This is a very welcomed change that will affect the way we implement types that represent JSON requests and responses; and actually the oapi-codegen
project is embracing this change in a future release:
I'm glad oapi-codegen, the OpenAPI Golang code generator, embraces the new omitzero changes, using pointers for all non-required fields is a bit annoying.https://t.co/ZlTdSwC5Yc
— Mario Carrion (@mariocarrion) February 17, 2025
I really like the addition of this new struct field option, it gives us more flexibility when having complex fields that require logic when marshaling them, and stop us from using pointers and omitempty
all over the place.
strings
package updatesCode for this example is available on Github.
Expanding the iterator support that was added in Go 1.23, the strings
package implements five new functions that interact with iterators:
Lines
: returns an iterator that uses "\n"
to split the string, keeping the separator,SplitSeq
: returns an iterator that uses an argument as separator, but does not keep it,SplitAfterSeq
: similar to SplitSeq
, however this function keeps the separator,FieldsSeq
: returns an iterator that uses whitespace characters, using unicode.IsSpace()
, to split the string, does not keep those characters, andFieldsFuncSeq
: similar to FieldsSeq
, however uses a function to determine what separator to use.Take the following example:
9func main() {
10 lines := `one
11two
12three`
13
14 //- strings.Lines
15 fmt.Println("- strings.Lines")
16 for v := range strings.Lines(lines) {
17 fmt.Printf("%s", v) // Keeps `\n`
18 }
19 fmt.Printf("\n")
20 //-
21
22 //- strings.SplitSeq
23 fmt.Println("- strings.SplitSeq")
24 for v := range strings.SplitSeq(lines, "\n") {
25 fmt.Printf("%s\n", v) // Does not keep "\n"
26 }
27
28 //- strings.SplitAfterSeq
29 fmt.Println("- strings.SplitAfterSeq")
30 for v := range strings.SplitAfterSeq(lines, "\n") {
31 fmt.Printf("%s", v) // Keeps "\n"
32 }
33 fmt.Printf("\n")
34
35 //- strings.FieldsSeq
36 fmt.Println("- strings.FieldsSeq")
37 for v := range strings.FieldsSeq(lines) {
38 fmt.Printf("%s\n", v) // Does not keep "whitespace" character
39 }
40
41 //- strings.FieldsFuncSeq
42 fmt.Println("- strings.FieldsFuncSeq")
43 f := func(r rune) bool {
44 return unicode.IsSpace(r)
45 }
46 for v := range strings.FieldsFuncSeq(lines, f) {
47 fmt.Printf("%s\n", v)
48 }
49}
The output is be same in all cases, it’s just how we configure the functions and the values we print out after each iteration.
testing
Context methodCode for this example is available on Github.
The type T
in the testing
package implements a new method called Context()
that returns a context.Canceled
after all tests complete, but before the t.Cleanup
methods are called. This feature could be used to verify logic requiring a context.Context
is executed as part of the normal testing suite.
For example, take the following Server
type:
3type Server struct {
4 done chan struct{}
5 stopped bool
6}
It defines the following methods to allow customers to listen and verify when the Server
was stopped:
3func (s *Server) Stop() {
4 // clean up resources, etc
5 close(s.done)
6 s.stopped = true // TODO: use a sync.RWMutex
7}
8
9func (s *Server) Stopped() bool {
10 return s.stopped
11}
12
13func (s *Server) Done() <-chan struct{} {
14 return s.done // TODO: use a sync.RWMutex
15}
Done()
: returns a channel customers can use to wait until it closes, therefore indicating the Server
is closed for good,Stopped()
: returns a boolean indicating whether the Server
closed already or not, andStop()
: closes the Server
, perhaps releasing resources like database connections, and closes the channel to indicate customers the Server
is done.If we put those methods together with the new t.Context()
method, we get something like the following:
9func TestRun(t *testing.T) {
10 srv := example.NewServer()
11
12 go func() {
13 <-t.Context().Done()
14 t.Log("context canceled")
15 srv.Stop()
16 }()
17
18 t.Cleanup(func() {
19 <-srv.Done()
20 t.Log("server stopped")
21
22 if !srv.Stopped() {
23 t.Fatal("server not stopped")
24 }
25 })
26
27 got := srv.Multiply(10, 2)
28 if got != 20 {
29 t.Fatalf("got: %d, want: 20", got)
30 }
31
32 t.Log("test finished")
33}
t.Context().Done()
returns, and calls Stop()
,Cleanup()
waits for the Server.Done()
to complete, this only happens after the goroutine above stops the server.This new Context()
method is useful to verify logic we have that happens to use the context.Context
type, typically during teardown, when closing or stopping services, and releasing resources; great addition to the standard library.
testing/synctest
packageCode for this example is available on Github.
Last, but not least the new experimental testing/synctest
package provides support for testing concurrent code, and not only that, it provides a fake clock.
Take for example a code like the following:
5type Pusher interface {
6 Push(int64) error
7}
8
9func Retry(retries, value int64, pusher Pusher) error {
10 var err error
11
12 for range retries {
13 err = pusher.Push(value)
14 if err != nil {
15 time.Sleep(1 * time.Second)
16 } else {
17 break
18 }
19 }
20
21 return err
22}
Where our goal is to test Retry
works as expected by sleeping 1 second every time pusher
fails, so an initial test like:
10func TestRun(t *testing.T) {
11 t.Parallel()
12
13 mock := &mockPusher{
14 errs: []error{errors.New("first"), errors.New("second"), nil},
15 }
16
17 gotErr := example.Retry(3, 10, mock)
18 if gotErr != nil {
19 t.Fatalf("got: %v, want: nil", gotErr)
20 }
21 if mock.retries != 3 {
22 t.Fatalf("got: %d, want: 3", mock.retries)
23 }
24 if mock.value != 10 {
25 t.Fatalf("got: %d, want: 10", mock.value)
26 }
27}
Works, and will cover the logic we implemented, but it will take (at least 2 seconds) to run:
go test -v ./...
=== RUN TestRun
=== PAUSE TestRun
=== CONT TestRun
--- PASS: TestRun (2.00s)
PASS
ok github.com/MarioCarrion/videos/2025/go-1-24-0/05-testing 2.493s
If we change the implementation to import the new package testing/synctest
and wrap the test using synctest.Run()
, we get a different result:
GOEXPERIMENT=synctest go test -v ./...
=== RUN TestRun
=== PAUSE TestRun
=== CONT TestRun
--- PASS: TestRun (0.00s)
PASS
ok github.com/MarioCarrion/videos/2025/go-1-24-0/05-testing
This improves the time it takes to run tests that depend on time.Time
without defining a mock in advance.
Since I started covering releases, I think Go 1.24 is perhaps the one with the most changes “under the hood”. I like that; I can always appreciate performance improvements.
New features such as the tool
command and the omitzero
struct field option let us know that the Go team listens our feedback every six months. However, I hope the surveys don’t sway them in the wrong direction; considering the recent discussions about changing the way errors work (yet one more time), we’ll see what changes in the future.
In the end, I think the Go team is doing a great job. As usual I’m looking forward to Go 1.25 later this year!
If you’re looking to sink your teeth into more Go-related topics I recommend the following books: