rss resume / curriculum vitae linkedin linkedin gitlab github twitter mastodon instagram
What is new in Go 1.24?
Feb 23, 2025

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! ๐Ÿ’ฅ ๐ŸŽ‰ ๐ŸŽŠ

Updating

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:

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


What is new?

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 command

Code 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:

  1. The Go toolchain is required in order to run the versioned tools,
  2. Tools such as 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.

Directory-limited filesystem access

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:

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 omitzero

Code 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, and
  • ZeroValue1.

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 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 updates

Code 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, and
  • FieldsFuncSeq: 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 method

Code 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, and
  • Stop(): 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}
  • L12-16: Start a new goroutine that blocks until t.Context().Done() returns, and calls Stop(),
  • L18-25: the 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.

New experimental testing/synctest package

Code 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.

Conclusion

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:


Back to posts