Last Updated: May 22, 2026
A goroutine is a lightweight unit of execution managed by the Go runtime. You start one by putting the keyword go in front of a function call, and the function then runs concurrently with the rest of your program. This lesson covers the go keyword, the two ways to launch a goroutine, the loop-variable trap that changed in Go 1.22, and why you can't get values back out of a goroutine with a plain return.
A goroutine is a function call that runs concurrently with the caller instead of blocking it. The runtime takes care of scheduling it onto an operating system thread, growing its stack as needed, and cleaning it up when it returns. From your code's point of view, a goroutine is "this function, but in the background."
That's a normal program with one goroutine: main. Every Go program already has at least one goroutine running, the one that starts when the program launches and runs main. Adding the go keyword starts another one alongside it. Here we focus on the surface syntax and behavior.
go KeywordPutting go in front of any function call launches that call as a new goroutine. The call returns immediately. Whatever the function would normally return is discarded, and the caller keeps going without waiting.
The three notifyShipped calls run as separate goroutines, so the calls in main don't block. Second, the order of the print lines is not guaranteed; the runtime is free to schedule them in any order. Run this program a few times and you'll see different orderings. Third, the time.Sleep call in main is doing real work here: without it, main would return before the goroutines got a chance to run, and the program would exit. We'll come back to that point in a moment.
The go statement is one of the smallest pieces of syntax in the language and one of the most consequential. It's the entire concurrency entry point. There's no Thread.start(), no executor.submit(), no Promise.new(). Just go funcCall(args).
The arguments to a go call are evaluated in the calling goroutine, at the point of the go statement. They're not deferred until the new goroutine actually starts running. This matters because if the values change between when you spawn the goroutine and when it runs, the goroutine sees the values from spawn time, not the current values.
The goroutine got copies of product and price at the moment the go call ran, so the later reassignments to those variables have no effect on what the goroutine prints. This is the same value-copy behavior as a regular function call, the difference is just that the function runs concurrently.
Cost: Arguments are copied into the goroutine's call frame, exactly like any other function call. For large structs, the copy cost applies once per spawn. Pass a pointer if you need to share state, but then you also need to think about whether two goroutines might write to the same memory at the same time.
You don't need a named function to launch a goroutine. The go keyword also accepts an anonymous function call. The shape is go func() { ... }(), with the trailing () actually invoking the function.
The trailing () is easy to forget. If you write go func() { ... } without the call, the program defines a function literal and then tries to go something that isn't a call, which is a compile error: expression in go must be function call.
Anonymous goroutines are useful for one-off background work that doesn't deserve its own named function. A common pattern is launching a quick fire-and-forget task with values from the surrounding scope:
The anonymous function reads customer and cart from the enclosing scope. That's a closure. Closures and go together are flexible, but they're also the source of the most common goroutine bug, which is what the next section is about.
You can also pass arguments to an anonymous goroutine explicitly:
The "Alex" argument is passed in just like a normal function call. The difference between closing over a variable and passing it as an argument matters when the variable can change after the go statement; we'll see exactly why in the next section.
If you wanted to send 1,000 shipping notifications in sequence, you'd write a loop and call the function 1,000 times one after another. Each call has to finish before the next one starts. If each call takes 50 milliseconds (most of that waiting on the network), the whole batch takes 50 seconds.
Five calls, each 50ms, total around 250ms. Now the same work with goroutines:
Five calls run in parallel and the total time drops to roughly the cost of one call plus a small amount of overhead. The order of the lines is no longer predictable because the runtime decides which goroutine runs first.
This is the value of goroutines: when work spends most of its time waiting on something else (a network reply, a disk read, a downstream service), running many pieces of that work at once finishes the batch much faster, without you having to manage threads or callbacks. And goroutines are cheap. A few thousand of them use a few megabytes of memory total. You can spawn one per request, per item, per page, without thinking about it the way you'd think about OS threads.
The diagram shows the spawn pattern. main runs through the loop, launches five child goroutines in quick succession, and continues on its own path. The five children run concurrently with main and with each other. None of them blocks the next, and none of them blocks main. The only thing keeping the program alive long enough to see the output is the time.Sleep in main.
A common bug with goroutines in loops: you write a loop that spawns one goroutine per iteration, and each goroutine reads the loop variable. What you expect is each goroutine sees a different value. What you get (in Go 1.21 and earlier) is all of them seeing the last value.
In Go 1.21 and earlier, this program printed processing order 103 three times. The reason: there was exactly one i variable for the whole loop. All three goroutines closed over the same i. By the time any of them actually ran, the loop had finished and i was 3, so orders[i] panicked or printed the wrong element. The same trap existed for for k, v := range slice, where every goroutine saw the final k and v.
The classic pre-1.22 fix was to pass the value as an argument so each goroutine got its own copy:
By passing i in explicitly, each goroutine got its own idx parameter, and the value was captured at spawn time. The other common fix was to shadow the variable inside the loop body with i := i, creating a fresh i per iteration that the goroutine could close over safely.
Go 1.22 fixed this at the language level. Starting with Go 1.22, the loop variable in a for statement has per-iteration scope by default. Each pass of the loop gets its own fresh i, so a goroutine that closes over i captures that iteration's copy. The first program above (the one that used to be a bug) now prints all three orders correctly on Go 1.22 or newer.
The fix in 1.22 is a small language change with a big practical impact; loop-variable capture was an extremely common goroutine bug before it. For now, two takeaways:
A normal function call hands a value back through return. A goroutine can't. The go statement doesn't capture the function's return value, and there's no syntax for the caller to "wait for the result" of a goroutine the way it would for a regular call.
The go computeTotal(cart) call launches the goroutine. Whatever computeTotal returns is thrown away. There's no variable on the left because there can't be: the caller isn't waiting for the goroutine, so there's no point in time at which the result is available to assign.
One workaround that looks tempting is to have the goroutine write into a shared variable:
That output looks right, but the program has two real problems. First, main is reading total while the goroutine is writing it, with no synchronization between them. That's a data race, and the Go race detector will flag it. Second, main uses time.Sleep to "wait" for the goroutine to finish, which is a guess, not a guarantee. If the goroutine takes longer than 100ms, main prints a partial sum or zero. If it takes less, the program wastes time sleeping.
time.Sleep is a teaching shortcut for this chapter only. It lets us see goroutine output without dragging in synchronization primitives we haven't covered yet. In real code, you never use time.Sleep to wait for goroutines. Two alternatives provide real synchronization:
Both tools give you real synchronization, not a hopeful sleep. For now, treat the time.Sleep calls in this chapter as a placeholder. Every example that uses one is leaning on a crutch we'll throw away in the next chapter.
Cost: A time.Sleep(100 * time.Millisecond) call costs you that 100ms whether the goroutines finished in 5ms or never started. It also doesn't catch the case where a goroutine takes longer than expected. Production code that uses time.Sleep to "wait for work" is a leak or a race or both.
Two patterns from this chapter deserve a second look because they're the foundation of nearly every goroutine bug.
The first is forgetting that main returning kills every goroutine. The runtime doesn't wait for background goroutines to finish; it stops them where they stand.
The go statement runs. The goroutine is scheduled. main returns on the next line. The program exits. Whether the goroutine got to print anything depends on whether the scheduler picked it up before main finished, and that's not something you should bet on.
The second is the time-sleep-as-synchronization habit. If you find yourself using time.Sleep to "give the goroutine time to finish", use sync.WaitGroup instead. sync.WaitGroup is the tool that lets main wait for the right amount of time, no more and no less.