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.
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)
}
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)
}
./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)
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:
// 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:
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
. . .
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
{"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
# 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:
{"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.
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:
{"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:
{"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 async.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)
}
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)
}
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)
}
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)
}
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)
}
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
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:
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:
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:
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!
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 usBuild 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.comor submit a pull request and help us build better products for everyone.
See the full list of amazing projects on github