Cory LaNou

Cory LaNou

Go 1.26: The APIs You'll Use Every Day

Overview

Go (golang) 1.26 shipped in February of 2026. The headlines went to the Green Tea garbage collector and the new SIMD package. Those are great, but they aren't the changes you'll notice in your day to day code. The changes you'll notice are smaller: new now accepts an expression, errors.AsType gives us a generic errors.As, and slog.NewMultiHandler lets one logger write to many destinations. In this article, we cover each one by example.

Target Audience

This article is aimed at developers that have minimum experience with Go.

In this article, we'll cover the following topics:

  • Using the new expression form of the new builtin
  • Replacing errors.As with the new generic errors.AsType
  • Sending logs to multiple destinations with slog.NewMultiHandler
  • A quick tour of other notable Go 1.26 additions
  • Finally, we'll end with an example that puts all of these together

new(expr)

Go has always made it awkward to get a pointer to a value. You can't take the address of a literal, so if a struct field is *int or *bool (common in configs and APIs where nil means "not set"), you had to write a helper function. Every Go codebase I've worked in has some version of this:

type Server struct {
	Name string
	Port *int
	TLS  *bool
}

func ptr[T any](v T) *T {
	return &v
}

func main() {
	srv := Server{
		Name: "api",
		Port: ptr(8080),
		TLS:  ptr(true),
	}

	fmt.Printf("name: %s, port: %d, tls: %t\n", srv.Name, *srv.Port, *srv.TLS)
}
$ go run .

name: api, port: 8080, tls: true

--------------------------------------------------------------------------------
Go Version: go1.26.2

The ptr helper works, but it's boilerplate, and every team writes it, names it something different, and sticks it in a different package.

In Go 1.26, the built-in new function accepts an expression. It allocates a new variable, initializes it with the value of the expression, and returns the pointer. The helper function is gone:

type Server struct {
	Name string
	Port *int
	TLS  *bool
}

func main() {
	srv := Server{
		Name: "api",
		Port: new(8080),
		TLS:  new(true),
	}

	fmt.Printf("name: %s, port: %d, tls: %t\n", srv.Name, *srv.Port, *srv.TLS)
}
$ go run .

name: api, port: 8080, tls: true

--------------------------------------------------------------------------------
Go Version: go1.26.2

As you can see, the output is exactly the same, we just didn't have to write a helper function to get it.

The expression doesn't have to be a literal. Any expression works, including function calls:

func defaultPort(tls bool) int {
	if tls {
		return 443
	}
	return 80
}

func main() {
	port := new(defaultPort(true))

	fmt.Printf("port: %d\n", *port)
}
$ go run .

port: 443

--------------------------------------------------------------------------------
Go Version: go1.26.2

Notice that new accepted the result of the function call, and gave us back a pointer to that value.

errors.AsType

errors.AsType is a generic version of errors.As. To appreciate it, let's look at what we've been writing since Go 1.13. To check if an error tree contains a specific error type, you declare a variable first, then pass a pointer to it:

type NotFoundError struct {
	ID int
}

func (e *NotFoundError) Error() string {
	return fmt.Sprintf("record %d not found", e.ID)
}

func findRecord(id int) error {
	return fmt.Errorf("lookup failed: %w", &NotFoundError{ID: id})
}

func main() {
	err := findRecord(42)

	var nfe *NotFoundError
	if errors.As(err, &nfe) {
		fmt.Printf("missing record id: %d\n", nfe.ID)
	}
}
$ go run .

missing record id: 42

--------------------------------------------------------------------------------
Go Version: go1.26.2

This works, but it has always bothered me. The var declaration sits outside the if statement, the variable is still in scope after the check, and errors.As will panic at runtime if you pass it the wrong thing. The compiler can't help you.

errors.AsType fixes all of that. The target type is a type parameter, so the compiler checks it. The result and the boolean come back as return values, so they scope cleanly to the if statement:

type NotFoundError struct {
	ID int
}

func (e *NotFoundError) Error() string {
	return fmt.Sprintf("record %d not found", e.ID)
}

func findRecord(id int) error {
	return fmt.Errorf("lookup failed: %w", &NotFoundError{ID: id})
}

func main() {
	err := findRecord(42)

	if nfe, ok := errors.AsType[*NotFoundError](err); ok {
		fmt.Printf("missing record id: %d\n", nfe.ID)
	}
}
$ go run .

missing record id: 42

--------------------------------------------------------------------------------
Go Version: go1.26.2

The behavior is the same, and the compiler can now check the type for you. The release notes also mention it is faster than errors.As. This is one of those functions you'll switch to and never look back.

slog.NewMultiHandler

If you read my slog article, you know I'm a fan of structured logging. One thing that has been missing from slog is a built-in way to send one log record to multiple destinations. Text to the console for humans, JSON to a file or shipping service for machines. Until now you needed a third party package or your own handler implementation.

Go 1.26 adds slog.NewMultiHandler. It takes any number of handlers and fans each record out to all of them:

func main() {
	// Drop the time attribute so the output is stable for this example.
	noTime := func(groups []string, a slog.Attr) slog.Attr {
		if a.Key == slog.TimeKey {
			return slog.Attr{}
		}
		return a
	}

	// Handler 1: human readable text to stdout.
	text := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
		ReplaceAttr: noTime,
	})

	// Handler 2: JSON to a buffer. In a real application this would be
	// a log file or a log shipping service.
	buf := &bytes.Buffer{}
	json := slog.NewJSONHandler(buf, &slog.HandlerOptions{
		ReplaceAttr: noTime,
	})

	logger := slog.New(slog.NewMultiHandler(text, json))

	logger.Info("order created", "order_id", 1234, "total", 49.99)

	fmt.Print("json buffer: ", buf.String())
}
$ go run .

level=INFO msg="order created" order_id=1234 total=49.99
json buffer: {"level":"INFO","msg":"order created","order_id":1234,"total":49.99}

--------------------------------------------------------------------------------
Go Version: go1.26.2

Notice that a single logger.Info call produced both outputs, each in its own format. I'm stripping the time attribute with ReplaceAttr only to keep this example's output stable. In real code you'd leave it in.

Other Notable Additions

A few more Go 1.26 changes worth knowing about:

  • io.ReadAll was rewritten. It's often about twice as fast and typically allocates around half as much memory. You get this for free by upgrading.
  • bytes.Buffer.Peek returns the next n bytes from the buffer without advancing it.
  • testing.T.ArtifactDir gives tests a directory for output files. Run with go test -artifacts to keep them after the test finishes.
  • The reflect package gained iterator methods like Type.Fields and Value.Fields, so you can range over struct fields directly.

Putting It Together

Here's a small example that uses all three of the main features. We have a config struct with optional pointer fields, an order function that returns a wrapped custom error, and a logger that writes text to the console while shipping JSON to a buffer:

type Config struct {
	Name       string
	MaxRetries *int
	Debug      *bool
}

type OutOfStockError struct {
	Item string
}

func (e *OutOfStockError) Error() string {
	return fmt.Sprintf("%s is out of stock", e.Item)
}

func order(item string, qty int) error {
	if item == "gopher plush" {
		return fmt.Errorf("order failed: %w", &OutOfStockError{Item: item})
	}
	return nil
}

func main() {
	noTime := func(groups []string, a slog.Attr) slog.Attr {
		if a.Key == slog.TimeKey {
			return slog.Attr{}
		}
		return a
	}

	buf := &bytes.Buffer{}
	logger := slog.New(slog.NewMultiHandler(
		slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: noTime}),
		slog.NewJSONHandler(buf, &slog.HandlerOptions{ReplaceAttr: noTime}),
	))

	// new(expr) removes the need for a pointer helper function.
	cfg := Config{
		Name:       "store",
		MaxRetries: new(3),
		Debug:      new(true),
	}

	logger.Info("starting", "name", cfg.Name, "max_retries", *cfg.MaxRetries, "debug", *cfg.Debug)

	err := order("gopher plush", 2)

	// errors.AsType replaces the errors.As two-step.
	if oos, ok := errors.AsType[*OutOfStockError](err); ok {
		logger.Warn("restock needed", "item", oos.Item)
	}

	fmt.Print("shipped to log service:\n", buf.String())
}
$ go run .

level=INFO msg=starting name=store max_retries=3 debug=true
level=WARN msg="restock needed" item="gopher plush"
shipped to log service:
{"level":"INFO","msg":"starting","name":"store","max_retries":3,"debug":true}
{"level":"WARN","msg":"restock needed","item":"gopher plush"}

--------------------------------------------------------------------------------
Go Version: go1.26.2

As you can see, the pointer helper function, the dangling var declaration, and the custom fan-out handler are all gone. Each one used to be extra code, and we got to delete all of it.

Summary

Go 1.26's big features got the attention, but new(expr), errors.AsType, and slog.NewMultiHandler are the ones that will show up in your pull requests. Small additions like these are my favorite kind of release. There's nothing new to learn, you just get to write less code.

Want More?

If you've enjoyed reading this article, you may find these articles interesting as well:

Ready to level up your Go?

25+ years of combined Go expertise. Fortune 500 clients. Real results.

Go Training

Instructor-led courses—in-person, virtual, or self-paced—that transform your team.

Get Training

Consulting

Expert guidance when you need it—architecture, code review, best practices.

Start Consulting

Code Audit

Deep dive into your codebase. Find issues before they find you.

Request Audit