A goroutine is a function executing concurrently with other goroutines in the same process. Starting one is easy; owning its lifetime is the important part.
Reviewer’s note: This tutorial treats goroutines as owned work: every goroutine gets a completion path, a cancellation path, and a clear owner for errors and shutdown. The examples focus on preventing leaks rather than merely starting concurrent functions.

Start a goroutine
go func() {
fmt.Println("working")
}()
The program does not wait automatically. If main returns, all goroutines stop. Use synchronization rather than sleeping and hoping work finishes.
Wait for known work
var wg sync.WaitGroup
for _, item := range items {
item := item
wg.Add(1)
go func() {
defer wg.Done()
process(item)
}()
}
wg.Wait()
See the complete WaitGroup guide for ordering mistakes and safe result collection.
Bound concurrency
Launching one goroutine for every unbounded input can exhaust memory, connections, or downstream capacity. Use a semaphore or worker pool to set an intentional limit.

Propagate cancellation
Long-lived goroutines should have a defined stop condition:
func watch(ctx context.Context, updates <-chan Update) {
for {
select {
case update, ok := <-updates:
if !ok { return }
apply(update)
case <-ctx.Done():
return
}
}
}
Capture errors
Errors returned inside a goroutine do not travel anywhere automatically. Send them on a channel, collect them in protected state, or use a structured concurrency helper.
Test for races and leaks
Run go test -race ./.... The race detector finds unsynchronized memory access, not every deadlock or leak. Design tests that cancel operations and assert they return promptly.
Every goroutine should have an owner, a stop condition, and a strategy for reporting failure. If you cannot explain those three things, keep the operation synchronous until you can.