Skip to content

Go Concurrency Patterns in 2026: What Go 1.25 Let Me Delete

Go Concurrency Patterns in 2026: What Go 1.25 Let Me Delete

I keep a folder of code I’ve deleted on purpose. Not the lost-to-a-bad-merge kind. The kind you remove because the language finally grew a better way to do the thing, and you want a small reminder that the old way is gone for good.

Last August a fair chunk went into that folder: most of the wg.Add(1) calls I’d ever written.

Concurrency has always been Go’s headline feature. Goroutines and channels shipped in version one, and they hold up fine. But the patterns most of us built on top of them aged badly. The WaitGroup dance. The semaphore you hand-build out of a buffered channel. The time.Sleep in a test that quietly hopes the CI runner isn’t having a slow morning. None of it was wrong. It was just boilerplate we’d all silently agreed to put up with.

Go 1.25 shipped last August and absorbed enough of that boilerplate to be worth a post. So here’s a tour of the Go concurrency patterns I actually reach for in 2026: what the standard library finally took over, what moved to errgroup, and what’s still completely your problem because no API is going to save you from it.

The WaitGroup dance I stopped doing

Here’s the pattern every Go developer has typed a few hundred times. Spin up a goroutine per item, track them all with a WaitGroup, wait for the lot to finish:

var wg sync.WaitGroup
for _, url := range urls {
    wg.Add(1)
    go func(u string) {
        defer wg.Done()
        fetch(u)
    }(u)
}
wg.Wait()

Three things in there are pure ceremony. The wg.Add(1) that has to match a wg.Done() living somewhere else. The defer wg.Done() you will forget exactly once, and then lose an afternoon to a hang you can’t explain. And the func(u string) with its (u) at the bottom, copying the loop variable so the closure doesn’t grab the wrong one.

Go 1.22 already dealt with the third problem by giving every loop iteration its own variable. Go 1.25 dealt with the other two by adding a WaitGroup.Go method. Same loop, now:

var wg sync.WaitGroup
for _, url := range urls {
    wg.Go(func() {
        fetch(url)
    })
}
wg.Wait()

Go bumps the counter, runs your function in a goroutine, and decrements when it returns. The Go 1.25 release notes call it a convenience, which sells it a bit short. The real win is that a mismatched Add/Done pair is now impossible to write. If it compiles, the counting is correct. That’s a whole class of bug gone, not just a few keystrokes.

errgroup is still the one I reach for most

WaitGroup.Go is great for fire-and-forget work. It has one blind spot: it can’t tell you anything went wrong. The functions it runs return nothing, so if fetch fails inside one of those goroutines, you find out never.

Most real concurrent work needs to do three things at once: wait for everything to finish, keep the first error, and stop the rest once that error shows up. That’s exactly the job golang.org/x/sync/errgroup has done for years, and 1.25 didn’t try to replace it:

g, ctx := errgroup.WithContext(ctx)
for _, url := range urls {
    g.Go(func() error {
        return fetch(ctx, url)
    })
}
if err := g.Wait(); err != nil {
    return fmt.Errorf("fetch batch: %w", err)
}

WithContext is the part people skip, and they shouldn’t. It hands you a derived context that gets cancelled the moment any Go function returns a non-nil error. If you actually pass that ctx down into fetch, one failure stops the other twenty requests still in flight, instead of letting them burn time and rate-limit budget on work whose result you’re about to throw away. The errgroup package docs lay out the exact cancellation rules, and they’re worth two minutes before you rely on them.

My rule of thumb: if the goroutines can fail, use errgroup. If they genuinely can’t, WaitGroup.Go. I land on the first one far more often than I expected to.

Bounded concurrency without the channel trick

One goroutine per item is fine for ten URLs. Try it on fifty thousand and you’ll open fifty thousand sockets, or grab fifty thousand database connections, and something downstream will tip over.

For years the fix was a buffered channel used as a counting semaphore:

sem := make(chan struct{}, 10)
for _, url := range urls {
    sem <- struct{}{}
    go func() {
        defer func() { <-sem }()
        fetch(url)
    }()
}

It works. It also reads like a riddle. Every time I came back to code like that I had to stop and work out whether sending or receiving was the “acquire” side. (Sending. It’s always sending. I still check.)

errgroup grew a SetLimit method that does the same job and actually says what it means:

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10)
for _, url := range urls {
    g.Go(func() error {
        return fetch(ctx, url)
    })
}
if err := g.Wait(); err != nil {
    return err
}

Once ten goroutines are running, the next g.Go call blocks until one of them finishes. You get bounded concurrency, error propagation, and context cancellation out of a single object, and whoever reads it next doesn’t have to decode a channel idiom first. I set the limit to match whatever the real bottleneck is, usually the connection pool size, rather than a round number that happens to look tidy.

Testing concurrency without sleep-and-pray

This is the change I’m happiest about, because flaky concurrency tests have eaten more of my life than the actual concurrency bugs ever did.

You know the test. Something is supposed to happen after a timeout, so the test sleeps a little longer than the timeout and then checks:

func TestCacheExpiry(t *testing.T) {
    c := NewCache(50 * time.Millisecond)
    c.Set("k", "v")
    time.Sleep(60 * time.Millisecond) // hope the CI box isn't loaded
    if _, ok := c.Get("k"); ok {
        t.Fatal("expected key to be expired")
    }
}

That 60ms is a bribe. Too short and the test fails on a busy CI runner. Too long and your whole suite drags. There’s no correct value, only values that fail a bit less often.

Go 1.25 promoted testing/synctest from an experiment to a real package, and it ends the bribery:

func TestCacheExpiry(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        c := NewCache(50 * time.Millisecond)
        c.Set("k", "v")
        time.Sleep(60 * time.Millisecond) // virtual time: instant and exact
        if _, ok := c.Get("k"); ok {
            t.Fatal("expected key to be expired")
        }
    })
}

The function runs inside what the Go team calls a “bubble”. Inside the bubble the clock is fake. time.Sleep doesn’t wait 60 real milliseconds. It advances a virtual clock the instant every goroutine in the bubble is blocked, so the test is deterministic and finishes in microseconds. There’s also synctest.Wait, which blocks until every other goroutine in the bubble is idle, so you can check state at an exact point without racing it. The Go blog’s synctest write-up and the testing/synctest docs cover the full rules, including which operations are allowed to cross the bubble boundary. One note if you used the experimental version: the old synctest.Run is deprecated, and synctest.Test is the API to write against now.

The patterns Go 1.25 didn’t fix

It would be nice to end with “concurrency is solved now”. It isn’t. A few things are still completely on you, and no shiny method on WaitGroup is coming to the rescue.

Goroutine leaks are the big one. errgroup waits for the goroutines it started. It has no idea about the one you spawned with a bare go and a channel it will block on until the heat death of the process. Every goroutine still needs a clear answer to one question: what guarantees this ends? Usually the answer is a context, which means a context has to actually reach it.

Context propagation is that same discipline wearing a different hat. errgroup.WithContext only cancels siblings if you thread its ctx all the way down to the call that actually blocks. A context that stops at the first function boundary is decoration. I’ve started treating a missing ctx argument on an I/O function the way I treat an ignored error return, a habit I wrote up properly in my post on Go error handling patterns.

Channel ownership hasn’t moved either. The goroutine that sends on a channel is the one that closes it, never a receiver, and never two senders racing to get there first. The newer APIs do help sideways here, because the channels you never hand-write can’t deadlock on you, and I lean on the range-based patterns from my notes on Go iterators for most of the iteration I used to wire up by hand.

What to do this week

Open your largest Go service and run grep -rn "wg.Add(1)" .. Every hit is a tiny migration. If those goroutines can’t fail, swap the block for wg.Go. If they can, you probably wanted errgroup the whole time. Then pick your slowest concurrency test, the one with a time.Sleep you’ve quietly resented for months, and move it into a synctest.Test bubble. None of this is a rewrite. It’s an afternoon of work, and it’s the same unglamorous cleanup I end up doing on most client codebases I take on: you finish with less code that does the same job and leaves fewer dark corners for bugs to sit in.