Back to Scaling Go Applications guides

What's New in Go 1.24

Go
Ayooluwa Isaiah
Updated on January 23, 2025

With Go 1.24 set to launch in February, now's the perfect time to dive into the latest features and improvements. While the official release notes are comprehensive, they can be a bit dense.

That's why I've put together this example-packed guide to showcase what's new and how these changes affect real-world code.

Let's explore the updates together—you're in for a treat!

Generic type aliases

In Go, a type alias is an alternative name for an existing type. It's like giving a nickname to something, but the underlying thing remains the same.

main.go
package main

import "fmt"

type MyString = string // Type alias
func main() { var a MyString = "hello" var b string = a // No conversion needed fmt.Println(a, b) }
Output
hello hello

This is different from a type definition where the new type is distinct from the original type, even if it has the same underlying structure:

 
package main

import "fmt"

type MyString string // Type definition
func main() { var a MyString = "hello" var b string = a // compile-time error fmt.Println(a, b) }
Output
./main.go:9:17: cannot use a (variable of type MyString) as string value in variable declaration

Even though MyString is based on string, they can not be used interchangeably. Explicit conversion is required:

 
package main

import "fmt"

type MyString string

func main() {
    var a MyString = "hello"
var b string = string(a) // Conversion is needed
fmt.Println(a, b) }

In Go 1.24, its now possible to create generic type aliases, making it easier to create reusable abstractions without introducing new types.

For instance, you can use a type alias to define a generic data structure such as a set:

 
type Set[T comparable] = map[T]struct{}

mySet := Set[string]{
    "apple":  {},
    "banana": {},
    "cherry": {},
}

fmt.Printf("mySet is of type: %T\n", mySet)
Output
mySet is of type: map[string]struct {}

Here, Set[T] is simply an alias for map[T]struct{}. Since it doesn't create a new type, mySet remains a map[string]struct{} under the hood, and functions expecting a map[T]struct{} will work seamlessly with it.

Tracking executable dependencies

Before Go 1.24, the recommended approach for tracking executable dependencies is by using a tools.go file that imports the packages providing the tools but doesn't use them in the code:

tools.go
// go:build tools

package tools

import (
    _ "github.com/golangci/golangci-lint/cmd/golangci-lint"
    _ "golang.org/x/tools/cmd/goimports"
)

The import statements enable the go command to accurately capture version details for your tools in the module's go.mod file, while the build constraint ensures that these tools are not included during normal builds.

In Go 1.24 and above, you can now add a tool directive for such dependencies directly to your go.mod file instead of following the tools.go pattern.

Here's the command to run:

 
go get -tool github.com/golangci/golangci-lint/cmd/golangci-lint

This will add the tool directive to your go.mod file, and ensure the necessary require directives are present:

go.mod
module github.com/betterstack-community/myProject

go 1.24

tool github.com/golangci/golangci-lint/cmd/golangci-lint
require ( . . . )

You can then run the tool with:

 
go tool golangci-lint

You can also view all the available tools with:

 
go tool
Output
. . .
pack
pprof
preprofile
test2json
trace
vet
github.com/golangci/golangci-lint/cmd/golangci-lint

See the documentation for more details.

In Go 1.10, a new -json flag was added to go test to enable the production of a machine-readable JSON-formatted description of test execution. This made it easy the creation of rich presentations of test execution in IDEs and other tools.

Now the same flag has been added to go build and go install to make their output easier to parse and analyze programmatically.

For example, here's the output from a failed go build with the --json flag:

 
go build --json
Output
{"ImportPath":"github.com/betterstack-community/go1.24","Action":"build-output","Output":"# github.com/betterstack-community/go1.24\n"}
{"ImportPath":"github.com/betterstack-community/go1.24","Action":"build-output","Output":"./main.go:36:11: undefined: Set\n"}
{"ImportPath":"github.com/betterstack-community/go1.24","Action":"build-output","Output":"./main.go:37:13: missing type in composite literal\n"}
{"ImportPath":"github.com/betterstack-community/go1.24","Action":"build-output","Output":"./main.go:38:13: missing type in composite literal\n"}
{"ImportPath":"github.com/betterstack-community/go1.24","Action":"build-output","Output":"./main.go:39:13: missing type in composite literal\n"}
{"ImportPath":"github.com/betterstack-community/go1.24","Action":"build-fail"}

The JSON output from go test was also enhanced with build output and failures in addition to the test results.

Prior to Go 1.24, a build failure will not be in JSON:

 
go test --json
Output
# github.com/betterstack-community/go1.24 [github.com/betterstack-community/go1.24.test]
./main.go:4:2: undefined: fmt
{"Time":"2025-01-19T22:14:40.432917134+01:00","Action":"start","Package":"github.com/betterstack-community/go1.24"}
{"Time":"2025-01-19T22:14:40.432977663+01:00","Action":"output","Package":"github.com/betterstack-community/go1.24","Output":"FAIL\tgithub.com/betterstack-community/go1.24 [build failed]\n"}
{"Time":"2025-01-19T22:14:40.432990132+01:00","Action":"fail","Package":"github.com/betterstack-community/go1.24","Elapsed":0}

But in Go 1.24, you'll see these logs in JSON format as build-output and build-failed Action types:

Output
{"ImportPath":"github.com/betterstack-community/go1.24 [github.com/betterstack-community/go1.24.test]","Action":"build-output","Output":"# github.com/betterstack-community/go1.24 [github.com
/betterstack-community/go1.24.test]\n"}
{"ImportPath":"github.com/betterstack-community/go1.24 [github.com/betterstack-community/go1.24.test]","Action":"build-output","Output":"./main.go:4:2: undefined: fmt\n"}
{"ImportPath":"github.com/betterstack-community/go1.24 [github.com/betterstack-community/go1.24.test]","Action":"build-fail"}
{"Time":"2025-01-19T22:14:29.236391906+01:00","Action":"start","Package":"github.com/betterstack-community/go1.24"} {"Time":"2025-01-19T22:14:29.236441887+01:00","Action":"output","Package":"github.com/betterstack-community/go1.24","Output":"FAIL\tgithub.com/betterstack-community/go1.24 [build failed]\n"} {"Time":"2025-01-19T22:14:29.236450409+01:00","Action":"fail","Package":"github.com/betterstack-community/go1.24","Elapsed":0,"FailedBuild":"github.com/betterstack-community/go1.24 [github.c om/betterstack-community/go1.24.test]"}

For further details, see the go help buildjson.

Omitting zero values in JSON

A common issue when working with JSON in Go is that the default marshaling behavior can include fields with zero values, which may not always be desired.

For example, a time.Time field with a zero value represents a valid date (January 1, 1, 00:00:00 UTC), but in many cases, you might want to omit it from the JSON output if it hasn't been explicitly set.

main.go
package main

import (
    "encoding/json"
    "fmt"
    "time"
)

type Invoice struct {
    ID       int       `json:"id"`
    Customer string    `json:"customer"`
    DueDate  time.Time `json:"due_date,omitempty"`
    Amount   float64   `json:"amount"`
}

func main() {
    invoice := Invoice{ID: 123, Customer: "Acme Corp", Amount: 100.00}
    b, _ := json.Marshal(invoice)
    fmt.Println(string(b))
}

The omitempty tag does not work here because the zero value for time.Time fields is a valid date:

Output
{"id":123,"customer":"Acme Corp","due_date":"0001-01-01T00:00:00Z","amount":100}

Go 1.24 addresses this issue by introducing the omitzero option for JSON field tags to provide a more precise way to exclude zero values during JSON marshaling.

This option is clearer and less error-prone than omitempty when the intent is specifically to exclude zero values:

 
type Invoice struct {
    ID       int       `json:"id"`
    Customer string    `json:"customer"`
DueDate time.Time `json:"due_date,omitzero"`
Amount float64 `json:"amount"` }

With this change, the DueDate field will be excluded from the JSON output if its not explicitly initialized:

Output
{"id":123,"customer":"Acme Corp","amount":100}

If the field type has a IsZero() bool method, it will be used to determine if the value is a zero value. Otherwise, the value is zero if it is the zero value for its type.

You can also use omitempty and omitzero together if you'd like to omit a field if its value is either empty or zero (or both).

 
type Invoice struct {
    ID       int       `json:"id"`
    Customer string    `json:"customer"`
DueDate time.Time `json:"due_date,omitzero,omitempty"`
Amount float64 `json:"amount"` }

Vet checks for safer code

Go 1.24 strengthens the go vet command with new and improved analyzers, providing even more robust static analysis to catch potential issues in your code and improve its safety. Here are some key updates:

New tests Analyzer

This new analyzer focuses specifically on your test code. It automatically examines your test functions, checking for common mistakes such as:

  • Malformed test names: Ensures your test function names follow the correct TestXxx format.
  • Invalid example functions: Verifies that example functions are correctly named and don't reference non-existent identifiers.

By catching these errors early, the tests analyzer helps you write more reliable and maintainable tests.

Enhanced analyzers

Several existing analyzers have also been improved:

  • printf: Now warns you if you use fmt.Printf with a runtime variable as the format string (e.g., fmt.Printf(s)). This helps prevent potential panics if the variable contains unexpected format specifiers.
  • buildtag: Flags invalid build constraints like //go:build go1.23.1, ensuring your build tags are correct.
  • copylock: Helps prevent subtle concurrency bugs by warning you if a loop variable holds a sync.Mutex or similar lock. This addresses a potential issue introduced in Go 1.22 where such loop variables are copied in each iteration.

New benchmark function

Go 1.24 introduces a streamlined approach to writing benchmarks with testing.B.Loop, addressing common pitfalls and making your benchmarks more efficient and reliable.

Before Go 1.24, benchmarks typically used a b.N for loop:

 
func BenchmarkReverseString(b *testing.B) {
    input := getString()
    b.ResetTimer()

    for range b.N {
        _ = reverseString(input)
    }
}

This approach, while functional, had some drawbacks:

  • The input string is recreated every time the benchmark function is called, even though it's constant across iterations.
  • You must explicitly call b.ResetTimer() to exclude the setup time from the measured results.
  • The _ = reverseString(input) line is needed to ensure the compiler doesn't optimize away the function call

With Go 1.24's b.Loop, these problems are resolved:

 
func BenchmarkReverseString(b *testing.B) {
    input := getString()

    b.Loop(func() {
        reverseString(input)
    })
}

Here, the input string is created only once, no matter how many iterations are executed. The setup time is also automatically excluded so there's no need to call b.ResetTimer(), and the compiler won't optimize away code within b.Loop.

Suppressing log output is made easier

When testing or benchmarking code that uses slog, it's often necessary to suppress log output to avoid cluttering the console. A common approach is to create a logger that discards all log entries.

 
package main

import (
    "io"
    "log/slog"
)

func main() {
    log := slog.New(
        slog.NewJSONHandler(io.Discard, nil),
    )
    log.Info("This will not be printed")
}

Here, the slog.JSONHandler is configured to send all log output to io.Discard, effectively silencing it.

In Go 1.24, the slog.DiscardHandler now achieves this automatically:

 
package main

import (
    "log/slog"
)

func main() {
    log := slog.New(slog.DiscardHandler)
    log.Info("This will not be printed")
}

Expanded iterators in strings and bytes Packages

Go 1.23 introduced a robust set of iterator functions, making string and byte processing more efficient and expressive. These iterators simplify common tasks like splitting strings or processing lines without requiring slices, offering a more memory-efficient approach.

In Go 1.24, a few new iterators have been added to both the strings and bytes packages, but we'll only demonstrate the strings variant below:

Lines()

The strings.Lines() function returns an iterator that processes newline-terminated lines in a string:

 
logData := "INFO: Started\nWARN: Disk almost full\nERROR: Out of memory"
for line := range strings.Lines(logData) {
    fmt.Println("Log:", line)
}
Output
Log: INFO: Started
Log: WARN: Disk almost full
Log: ERROR: Out of memory

SplitSeq()

The strings.SplitSeq() function returns an iterator that splits a string by a specified delimiter.

 
csvData := "apple,banana,cherry"
for value := range strings.SplitSeq(csvData, ",") {
    fmt.Println("Value:", value)
}
Output
Value: apple
Value: banana
Value: cherry

SplitAfterSeq

The strings.SplitAfterSeq() function returns an iterator that splits a string after each occurrence of the delimiter.

 
logData := "INFO: Started|WARN: Disk almost full|ERROR: Out of memory|"
for part := range strings.SplitAfterSeq(logData, "|") {
    fmt.Println("Log segment:", part)
}
Output
Log segment: INFO: Started|
Log segment: WARN: Disk almost full|
Log segment: ERROR: Out of memory|

FieldsSeq

The strings.FieldsSeq() function splits a string into substrings around runs of whitespace and provides them as an iterator.

 
command := "run --verbose --output=file.txt"
for arg := range strings.FieldsSeq(command) {
    fmt.Println("Argument:", arg)
}
Output
Argument: run
Argument: --verbose
Argument: --output=file.txt

FieldsFuncSeq

The strings.FieldsFuncSeq() function splits a string based on Unicode code points that satisfy a given predicate function.

 
text := "Hello, world! How are you?"
isPunctuation := func(c rune) bool {
    return !unicode.IsLetter(c) && !unicode.IsNumber(c)
}

for token := range strings.FieldsFuncSeq(text, isPunctuation) {
    fmt.Println("Token:", token)
}
Output
Token: Hello
Token: world
Token: How
Token: are
Token: you

Embedding module version in Go binaries

Go 1.24 enhances the go build command to automatically embed version control information into your compiled binaries. This makes it easier to track and identify the exact code version used to build an application.

You can access the build version with the following code:

 
package main

import (
    "fmt"
    "runtime/debug"
)

func main() {
info, _ := debug.ReadBuildInfo()
fmt.Println("Go version:", info.GoVersion)
fmt.Println("App version:", info.Main.Version)
}
 
go build -o myapp && ./myapp
Output
Go version: go1.24rc2
App version: v1.0.0+dirty

The version is determined based on the version control system (VCS) tag or commit. You can have tagged versions like v1.2.4 or v1.2.4+dirty, or untagged versions such as v1.2.3-0.20240620130020-daa7c0413123. The +dirty suffix is added if there are uncommitted changes in the repository at build time.

To omit version control information from the binary, use the -buildvcs=false flag:

 
go build -buildvcs=false -o myapp

In this case, the application version will always display (devel)

Testing with synthetic time

Testing time-sensitive code can be challenging, especially when dealing with long timeouts. Waiting for real-time durations in tests slows down development and can lead to unreliable results.

Go 1.24 addresses this with the experimental testing/synctest package, which introduces synthetic time for controlled and predictable testing.

Consider a function that retries an operation with exponential backoff until it succeeds or a maximum duration is reached:

 
// Retry retries the provided function f until it succeeds or the timeout is reached.
// It uses exponential backoff for retries, starting at 1 second and doubling each attempt.
func Retry(f func() error, timeout time.Duration) error {
    start := time.Now()
    delay := time.Second

    for {
        if err := f(); err == nil {
            return nil
        }

        if time.Since(start)+delay > timeout {
            return errors.New("operation timed out")
        }

        time.Sleep(delay)
        delay *= 2 // Exponential backoff
    }
}

Testing this function with real time would require waiting for the full timeout duration, which is inefficient. Using the testing/synctest package, we can speed up the process by simulating time progression.

 
func TestRetryTimeout(t *testing.T) {
    synctest.Run(func() {
        attempts := 0

        // Mock function that always fails
        f := func() error {
            attempts++
            return errors.New("failure")
        }

        // Set a timeout of 10 seconds
        timeout := 10 * time.Second

        // Call Retry and expect a timeout error
        err := Retry(f, timeout)
        if err == nil {
            t.Fatal("expected timeout error, got nil")
        }

        // Verify that the retry logic respected the timeout
        if attempts != 4 {
            t.Fatalf("expected 4 attempts, got %d", attempts)
        }
    })
}

With this setup, the time.Sleep() calls in Retry doesn't delay the test because the synctest.Run() environment uses synthetic time. The synthetic clock advances as each retry occurs, respecting the exponential backoff logic.

In the test, we assert that the retry logic performed four attempts, corresponding to the backoff sequence within the 10-second timeout.

Note that this feature is experimental and subject to change, so you must explicitly enable it by setting GOEXPERIMENT=synctest at build time:

 
GOEXPERIMENT=synctest go test

The output confirms that the function respects the timeout and that retries occur as expected:

Output
PASS
ok      github.com/betterstack-community/go1.24 0.002s

Using test context and directory isolation

Go 1.24 introduces several enhancements for testing, including improved context handling and working directory management.

Context management and cleanup

  • T.Context(): This method returns a context that is automatically canceled when the test completes. This simplifies resource cleanup and ensures tests don't leak resources.

  • T.Cleanup(): Register functions to be called when the test completes. This is useful for waiting for long-running operations to finish or cleaning up resources.

For example, imagine a data processor that processes files in the current working directory:

main.go
package main

import (
    "context"
    "os"
    "time"
)

type Processor struct {
    done chan struct{}
}

func (p *Processor) Process() int {
    return 42 // Simulated processing result
}

// Done returns a channel that is closed when the processor stops.
func (p *Processor) Done() <-chan struct{} {
    return p.done
}

// StartProcessor starts a processor that runs until the context is canceled.
func StartProcessor(ctx context.Context) *Processor {
    p := &Processor{done: make(chan struct{})}
    go func() {
        defer close(p.done)
        <-ctx.Done()
        // Simulate cleanup delay
        time.Sleep(100 * time.Millisecond)
    }()
    return p
}

To ensure the processor cleans up resources properly, we can use the T.Context method to manage its lifecycle during testing:

main_test.go
package main

import (
    "testing"
)

func TestProcessor(t *testing.T) {
    // Use t.Context to manage processor lifecycle.
    processor := StartProcessor(t.Context())

t.Cleanup(func() {
<-processor.Done() // Wait for the processor to complete cleanup.
})
if result := processor.Process(); result != 42 { t.Fatalf("unexpected result: %d", result) } }

Working directory management

Tests that interact with files in the current directory often require directory isolation. The t.Chdir() and b.Chdir() methods ensures each test runs in its own temporary directory while restoring the original directory afterward.

 
package main

import (
    "os"
    "testing"
)

func TestWorkingDirectoryIsolation(t *testing.T) {
    t.Run("temp_dir", func(t *testing.T) {
        // Change the working directory for this test.
        tmpDir := t.TempDir()
t.Chdir(tmpDir)
cwd, _ := os.Getwd() if cwd != tmpDir { t.Fatalf("expected cwd to be %s, got %s", tmpDir, cwd) } // Create a temporary file in the new working directory. if _, err := os.Create("temp_file.txt"); err != nil { t.Fatalf("failed to create file: %v", err) } }) t.Run("original_dir", func(t *testing.T) { // Ensure this test runs in the original working directory. cwd, _ := os.Getwd() if cwd == "/tmp" { // Replace with the temporary directory if needed t.Fatalf("unexpected cwd: %s", cwd) } }) }

Here's how you can test the processor and its file interactions in an isolated environment:

 
func TestProcessorWithFiles(t *testing.T) {
    t.Run("process_files", func(t *testing.T) {
        // Use a temporary directory for this test.
        tmpDir := t.TempDir()
        t.Chdir(tmpDir)

        // Create a sample file.
        if _, err := os.Create("input.txt"); err != nil {
            t.Fatalf("failed to create input file: %v", err)
        }

        // Start the processor with a managed context.
        processor := StartProcessor(t.Context())
        t.Cleanup(func() {
            <-processor.Done() // Ensure the processor completes cleanup.
        })

        // Verify processing result.
        if result := processor.Process(); result != 42 {
            t.Fatalf("unexpected result: %d", result)
        }
    })
}

Final thoughts

Go 1.24 is packed with exciting new features and enhancements. Highlights include the introduction of weak pointers and finalizers, making it easier to manage memory in advanced use cases. Directory-scoped filesystem access offers a more secure and granular way to handle file operations.

Performance improvements take center stage with faster map implementations, a change that developers will appreciate across a wide range of applications. The release also emphasizes developer experience, introducing tools for safer and more efficient benchmarking, better support for testing concurrent code, and simplified integration of custom tools.

On the cryptographic front, the addition of SHA-3 support and random text generation utilities further strengthens Go's already robust security capabilities.

With this release, Go continues to evolve as a developer-friendly and high-performance language. It's a fantastic step forward!

Author's avatar
Article by
Ayooluwa Isaiah
Ayo is a technical content manager at Better Stack. His passion is simplifying and communicating complex technical ideas effectively. His work was featured on several esteemed publications including LWN.net, Digital Ocean, and CSS-Tricks. When he's not writing or coding, he loves to travel, bike, and play tennis.
Got an article suggestion? Let us know
Next article
Dockerizing Go Applications: A Step-by-Step Guide
Learn how to run Go applications confidently within Docker containers either locally or on your chosen deployment platform
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

Make your mark

Join the writer's program

Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.

Write for us
Writer of the month
Marin Bezhanov
Marin is a software engineer and architect with a broad range of experience working...
Build on top of Better Stack

Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.

community@betterstack.com

or submit a pull request and help us build better products for everyone.

See the full list of amazing projects on github