Preventing accidental struct copies in Go

· 4 min

By default, Go copies values when you pass them around. But sometimes, that can be undesirable. For example, if you accidentally copy a mutex and multiple goroutines work on separate instances of the lock, they won’t be properly synchronized. In those cases, passing a pointer to the lock avoids the copy and works as expected.

Take this example: passing a sync.WaitGroup by value will break things in subtle ways:

func f(wg sync.WaitGroup) {
    // ... do something with the waitgroup
}

func main() {
    var wg sync.WaitGroup
    f(wg) // oops! wg is getting copied here!
}

sync.WaitGroup lets you wait for multiple goroutines to finish some work. Under the hood, it’s a struct with methods like Add, Done, and Wait to sync concurrently running goroutines.

Go 1.24's "tool" directive

· 4 min

Go 1.24 added a new tool directive that makes it easier to manage your project’s tooling.

I used to rely on Make targets to install and run tools like stringer, mockgen, and linters like gofumpt, goimports, staticcheck, and errcheck. Problem is, these installations were global, and they’d often clash between projects.

Another big issue was frequent version mismatch. I ran into cases where people were formatting the same codebase differently because they had different versions of the tools installed. Then CI would yell at everyone because it was always installing the latest version of the tools before running them. Chaos!

Capturing console output in Go tests

· 5 min

Ideally, every function that writes to the stdout probably should ask for a io.Writer and write to it instead. However, it’s common to encounter functions like this:

func frobnicate() {
    fmt.Println("do something")
}

This would be easier to test if frobnicate would ask for a writer to write to. For instance:

func frobnicate(w io.Writer) {
    fmt.Fprintln(w, "do something")
}

You could pass os.Stdout to frobnicate explicitly to write to the console:

Deferred teardown closure in Go testing

· 6 min

While watching Mitchell Hashimoto’s Advanced Testing with Go talk, I came across this neat technique for deferring teardown to the caller. Let’s say you have a helper function in a test that needs to perform some cleanup afterward.

You can’t run the teardown inside the helper itself because the test still needs the setup. For example, in the following case, the helper runs its teardown immediately:

func TestFoo(t *testing.T) {
    helper(t)

    // Test logic here: resources may already be cleaned up!
}

func helper(t *testing.T) {
    t.Helper()

    // Setup code here.

    // Teardown code here.
    defer func() {
        // Clean up something.
    }()
}

When helper is called, it defers its teardown - which executes at the end of the helper function, not the test. But the test logic still depends on whatever the helper set up. So this approach doesn’t work.

Three flavors of sorting Go slices

· 9 min

There are primarily three ways of sorting slices in Go. Early on, we had the verbose but flexible method of implementing sort.Interface to sort the elements in a slice. Later, Go 1.8 introduced sort.Slice to reduce boilerplate with inline comparison functions. Most recently, Go 1.21 brought generic sorting via the slices package, which offers a concise syntax and compile-time type safety.

These days, I mostly use the generic sorting syntax, but I wanted to document all three approaches for posterity.

Nil comparisons and Go interface

· 5 min

Comparing interface values in Go has caught me off guard a few times, especially with nils. Often, I’d expect a comparison to evaluate to true but got false instead.

Many moons ago, Russ Cox wrote a fantastic post on Go interface internals that clarified my confusion. This post is a distillation of my exploration of interfaces and nil comparisons.

Interface internals

Roughly speaking, an interface in Go has three components:

Stacked middleware vs embedded delegation in Go

· 5 min

Middleware is usually the go-to pattern in Go HTTP servers for tweaking request behavior. Typically, you wrap your base handler with layers of middleware - one might log every request, while another intercepts specific routes like /special to serve a custom response.

However, I often find the indirections introduced by this pattern a bit hard to read and debug. I recently came across the embedded delegation pattern while browsing Gin’s HTTP router source code. Here, I explore both patterns and explain why I usually start with delegation whenever I need to modify HTTP requests in my Go services.

Why does Go's io.Reader have such a weird signature?

· 3 min

I’ve always found the signature of io.Reader a bit odd:

type Reader interface {
    Read(p []byte) (n int, err error)
}

Why take a byte slice and write data into it? Wouldn’t it be simpler to create the slice inside Read, load the data, and return it instead?

// Hypothetical; what I *thought* it should be
Read() (p []byte, err error)

This felt more intuitive to me - you call Read, and it gives you a slice filled with data, no need to pass anything.

Go slice gotchas

· 10 min

Just like any other dynamically growable container structure, Go slices come with a few gotchas. I don’t always remember all the rules I need to be aware of. So this is an attempt to list some of the most common mistakes I’ve made at least once.

Slices are views over arrays

In Go, a slice is a lightweight wrapper around an array. Instead of storing data itself, it keeps track of three things: a pointer to an underlying array where the data is stored, the number of elements it currently holds, and the total capacity before it needs more space. The Go runtime defines it like this:

The domain knowledge dilemma

· 3 min

Seven years isn’t an awfully long time to work as an IC in the industry, but it’s enough to see a few cycles of change. One thing I’ve learned during this period is that, to be a key player in a business as an engineer, one of the biggest moats you can build for yourself is domain knowledge.

When you know the domain well, it becomes a lot easier to weather waves of technological and managerial change. This is especially true in businesses where the tech is mostly a fleet of services communicating over some form of RPC. Doing something novel in setups like that is often hard. In situations like these, picking up the domain quickly and being able to apply a template solution is probably one of the few edges we still have over LLMs.