Software

Golang Interview Questions: Concurrency, Goroutines and Channels

Talha AslanTalha Aslan 19 min read 1 views

Golang interview questions test how well a Go developer understands the language basics and, above all, the concurrency toolkit: goroutines, channels, select, the sync package and context. I have managed web projects since 2012, and I ask these questions from the other side of the table when I hire backend developers.

In this guide I keep the general Go questions short and spend most of the time on concurrency. Under each question you will find what the interviewer really measures and a short code sample. You can find more posts like this in the software category.

What are the most common golang interview questions?

The most common golang interview questions fall into four layers: language basics, goroutines and the scheduler, channels with select, and real patterns built on sync and context. In a mid level role, more than half of the questions usually cover concurrency, because teams pick Go for high traffic services.

So do not just memorise definitions. Instead, write small programs. For example, build a worker pool, a request with a timeout and a racy counter, then fix the counter. After that, most interview questions will feel familiar. You also get used to explaining code out loud, which matters just as much.

What does the interviewer really measure with Go questions?

The interviewer looks at three things. First, can you define the concept correctly? Second, can you see the risk? Third, can you explain how you used it in real code? Many candidates know the definition. However, few can explain how a goroutine leak shows up in production.

What I value most is a candidate who says "I am not sure, but here is how I would test it". For instance, someone who does not know the speed gap between sync.Map and a locked map, but offers to write a benchmark, sends a strong signal. In short, I care about the process as much as the answer.

  • Accuracy: explain goroutines, channels and mutexes clearly.
  • Risk awareness: recognise deadlocks, data races and leaks.
  • Practice: have real experience with the race detector, pprof and tests.
  • Communication: defend a design choice with reasons.

What is a goroutine and how does it differ from an OS thread?

A goroutine is a lightweight unit of execution that the Go runtime manages. You put the go keyword in front of a function call, and that function starts to run concurrently in the same address space. The key difference from an OS thread is that the Go runtime schedules it, not the kernel.

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        greet("Ada")
    }()
    wg.Wait()
}

According to the Go 1.4 release notes, a goroutine stack starts at 2048 bytes and grows when needed. That is why you can run tens of thousands of goroutines. However, a good answer does not stop there. Goroutines are cheap, not free. So code that starts goroutines without limits can still exhaust memory or a connection pool.

Note that the sample waits with a WaitGroup on purpose. A common trap is to wait with time.Sleep instead; the delay is either too short or wastes time. The interviewer will often ask how you would fix that. The right answer ties the wait to a synchronisation tool, such as a WaitGroup or a channel, instead of a fixed delay.

How does the Go scheduler work with the G, M and P model?

You can explain the Go scheduler with three letters. G is a goroutine, M is an OS thread, and P is a processor context. A G needs an M that holds a P in order to run. As a result, the number of threads that run Go code at the same time matches the number of Ps, which is GOMAXPROCS.

Each P has its own local run queue. When a P runs out of work, it steals work from other queues; this is work stealing. Also, when a goroutine blocks in a system call, the scheduler hands the P to another M. That way the other goroutines keep running.

Here the interviewer wants intuition, not trivia. For example, if you set GOMAXPROCS to 1, does concurrency stop? The answer is no. Concurrency continues, and only parallelism goes away. That distinction leads straight to the next question.

What is the difference between concurrency and parallelism in Go?

Concurrency means you structure a program so it can manage several tasks in overlapping time. Parallelism means those tasks physically run at the same moment on different cores. You can write a concurrent program on a single core machine. However, it cannot run in parallel there.

The Go team often puts it this way: concurrency is about design, parallelism is about execution. Therefore a strong answer says that Go gives you tools for concurrent design, while the runtime and the hardware provide parallelism.

ConceptMeaningGo tool
ConcurrencyManaging overlapping tasksgoroutine, channel, select
ParallelismRunning tasks at the same timeGOMAXPROCS, multiple cores
SynchronisationProtecting shared datasync.Mutex, sync/atomic
CancellationStopping work on timecontext.Context

What is a channel, and how do buffered and unbuffered channels differ?

A channel is a typed pipe that moves values between goroutines. Effective Go sums up the idea: do not communicate by sharing memory; instead, share memory by communicating. In other words, you hand over ownership of the data through a channel rather than guarding it with a lock.

ch := make(chan int)      // unbuffered
bch := make(chan int, 3)  // buffered, capacity 3

On an unbuffered channel, the sender waits until a receiver is ready. So the send and the receive happen together, and the two goroutines synchronise. On a buffered channel, the sender does not wait until the buffer fills. On the other hand, once the buffer is full, the sender blocks again.

A common trap follows: does a buffer fix a deadlock? Usually it does not. It only delays it. That is why you should say you size the buffer for the design, not for speed. This answer tends to leave a good impression.

What happens when you read from or write to a closed channel?

Reading from a closed channel does not panic. Once the buffered values run out, you get the zero value of the type. Writing to a closed channel, however, panics. Closing the same channel twice also panics. Interviewers ask about these three behaviours almost every time.

v, ok := <-ch
if !ok {
    // channel is closed and empty
}

The second value, ok, tells you whether the value came from a real send or from the close. Also, when you range over a channel, the loop ends on its own once the channel is closed and drained. Therefore the producer should close the channel when its work ends.

The practical rule is simple: only the sender closes a channel, never the receiver. When there are several senders, a separate goroutine coordinates the close with a WaitGroup. For example, you will see this approach in the fan in pattern.

How does a nil channel behave, and why is it useful in select?

Sending to a nil channel blocks forever, and so does receiving from one. At first this looks like a bug. In practice, though, it is a deliberate technique inside select. When you want to switch off a case, you set that channel variable to nil, and select stops picking that case.

for a != nil || b != nil {
    select {
    case v, ok := <-a:
        if !ok { a = nil; continue }
        fmt.Println(v)
    case v, ok := <-b:
        if !ok { b = nil; continue }
        fmt.Println(v)
    }
}

In this sample, two sources can close independently. Once you set the closed source to nil, the loop keeps reading from the other one. As a result, zero values from a closed channel do not keep the loop busy. A candidate who answers this well has usually shipped real channel code.

How does the select statement work, and what does default do?

Select waits on several channel operations at once and runs one of the ready cases. If more than one case is ready, Go picks one at random, so you cannot rely on order. If no case is ready and there is no default, select blocks.

select {
case msg := <-incoming:
    handle(msg)
case <-time.After(2 * time.Second):
    log.Println("timeout")
}

The default case lets you try without waiting. For example, a log writer can drop a message when its buffer is full. However, if you put default inside a for loop, you create a busy loop that burns CPU for nothing. Interviewers look for this mistake on purpose.

In long running loops they also expect you to reuse a time.Timer instead of calling time.After on every pass. That way you do not create a new timer each time.

When should you use a WaitGroup and when a Mutex?

You use a WaitGroup to wait for a group of goroutines to finish. A Mutex, in contrast, lets only one goroutine touch shared data at a time. Put simply, a WaitGroup answers "when is it done", and a Mutex answers "who may touch it".

var wg sync.WaitGroup
var mu sync.Mutex
total := 0
for i := 1; i <= 10; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        mu.Lock()
        total += i
        mu.Unlock()
    }()
}
wg.Wait()

The Go 1.25 release notes add a new Go method to WaitGroup. It folds the start and count pattern into a single call. If the team runs a recent version, knowing this earns points. Still, you should explain the older pattern too, because most existing codebases use Add and Done.

A classic mistake is to call wg.Add inside the goroutine. Then Wait may run before Add, and the program exits early.

When should you prefer RWMutex or sync/atomic?

RWMutex suits cases where reads far outnumber writes. Several readers can hold the lock at once, while a writer holds it alone. The sync/atomic package, on the other hand, updates simple values such as a counter or a flag safely without a lock.

However, RWMutex is not automatically faster for every read heavy structure. If the critical section is very short, its overhead can cost more than a plain Mutex. So in the interview it is right to say: "I would not decide without measuring; I would write a benchmark."

  • Single counter or flag: atomic.Int64 or atomic.Bool.
  • Struct with several fields that change together: sync.Mutex.
  • Many reads, rare writes and a long critical section: sync.RWMutex.
  • One time setup: sync.Once.

What is a data race, and how do you catch it with the race detector?

A data race happens when two goroutines access the same memory location at the same time, at least one of them writes, and nothing synchronises them. The Go memory model states that programs with races can behave in unpredictable ways. Therefore "it works on my machine" is not an answer.

go test -race ./...
go run -race main.go

The race detector watches memory access in a program you build with the -race flag and reports races with stack traces. However, it only sees code paths that actually run. It cannot catch a race on a path your tests never touch. That is why I recommend running the test suite with -race in CI.

According to the documentation, the race detector adds noticeable memory and CPU cost. So you do not keep it on in production all the time. Instead, you use it in load tests and in CI. Explaining this trade off shows that you have really used the tool.

How do deadlocks happen, and how do you prevent them?

A deadlock happens when goroutines wait on each other and none can move. If every goroutine blocks, the Go runtime stops the program with "all goroutines are asleep - deadlock!". However, if only some of them block, the program keeps running and the problem grows quietly.

func main() {
    ch := make(chan int)
    ch <- 1          // no receiver, waits forever
    fmt.Println(<-ch)
}

A few rules help. First, always take locks in the same order. Second, add a timeout or a context to channel operations so nothing waits forever. Third, avoid calling outside code while you hold a lock. For example, a channel send inside a locked section is a classic source of deadlock.

What is a goroutine leak, and how do you detect one?

A goroutine leak is a pile of goroutines that never exit even though their work has ended. The usual cause is a goroutine that tries to send on a channel nobody reads, or that waits on a channel that never closes. As a result, memory use creeps up and the service slows down days later.

func firstReply(urls []string) string {
    ch := make(chan string, len(urls)) // buffer prevents the leak
    for _, u := range urls {
        go func() { ch <- fetch(u) }()
    }
    return <-ch
}

Without the buffer, the remaining goroutines would block forever after the first reply. To detect leaks, you track runtime.NumGoroutine as a metric and take a goroutine profile with pprof. You can also check in tests that the goroutine count returns to its starting value.

Service speed is not only about backend code. The speed users feel on the front end ties directly to how site speed affects SEO.

How does the context package handle cancellation and timeouts?

A context carries a cancellation signal, a deadline and request scoped values across goroutines. When a client cancels an HTTP request, the database query and the outside API call tied to it stop on the same signal. As a result, no goroutine keeps working for nothing.

ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
select {
case res := <-process(ctx):
    write(w, res)
case <-ctx.Done():
    http.Error(w, ctx.Err().Error(), http.StatusGatewayTimeout)
}

The interviewer looks for three details. First, you must call cancel, usually with defer; otherwise resources leak. Second, you pass the context as the first argument, not as a struct field. Third, you use WithValue for request scoped data such as a trace ID, not for optional parameters.

How do you build a worker pool in Go?

A worker pool is a fixed number of goroutines that pull jobs from a shared queue. The goal is to cap concurrency so you do not overload outside resources, for example database connections. It is one of the most common live coding tasks.

func pool(jobs <-chan int, n int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup
    for w := 0; w < n; w++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for j := range jobs {
                out <- j * j
            }
        }()
    }
    go func() { wg.Wait(); close(out) }()
    return out
}

Note that a separate goroutine closes the out channel. The close happens after all workers finish, so the consumer can range over it safely. Also, directional channel types such as <-chan and chan<- let the compiler catch misuse.

What are the fan out, fan in and pipeline patterns?

A pipeline is a chain of stages linked by channels. Fan out spreads one stage across several goroutines. Fan in merges several channels into one. Interviewers usually ask about all three together.

Think of an image service with three stages: read files, resize them and save them. If resizing is the slowest step, you fan it out to four workers and fan the results back into one channel. That way you widen the bottleneck only where you need to.

  1. Each stage takes an input channel and returns an output channel.
  2. Each stage closes its own output channel when it ends.
  3. Every stage listens to the context and exits early on cancel.
  4. Fan in coordinates the close with a WaitGroup.

If you show this list in code, expect a follow up: why does a cancelled pipeline not leak?

How do errgroup and semaphores help with error handling?

The errgroup package lives in golang.org/x/sync. It runs a group of goroutines and returns the first error. When you create it with WithContext, one failing goroutine cancels the shared context, and the others stop too. In other words, you get a WaitGroup, error handling and cancellation in one package.

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

SetLimit caps how many goroutines run at once. So in simple cases you do not need to write your own semaphore. However, when you need weighted resource sharing, you reach for the semaphore package in the same module. Telling these two apart shows that you write real service code.

How did the loop variable trap change in Go 1.22?

Before Go 1.22, a for loop variable was one variable shared across all iterations. Because of that, a goroutine that captured it often printed the last value. According to the Go blog, since Go 1.22 each iteration gets its own variable, and this classic bug goes away.

for _, v := range []string{"a", "b", "c"} {
    go func() { fmt.Println(v) }() // Go 1.22+: a, b, c
}

Still, the interviewer may ask about the old behaviour. Modules whose go.mod declares an older go version keep the old semantics. A good answer covers the new rule and explains why older code has lines like v := v.

Version details like this matter in teams that upgrade on a schedule. I plan upgrades the same way in my own projects, and I described that discipline for the front end in my post on micro frontend architecture.

Why does GOMAXPROCS matter in containers?

GOMAXPROCS sets how many Ps can run Go code at once. For a long time the default was the number of logical CPUs on the machine. In Kubernetes, that could mean a value far above the container CPU limit, which caused needless throttling.

According to the Go 1.25 release notes, the runtime on Linux now considers the cgroup CPU bandwidth limit. If that limit is lower than the number of logical CPUs, GOMAXPROCS defaults to the lower limit. Moreover, the runtime updates the value periodically. If you set GOMAXPROCS by hand, however, this behaviour switches off.

When you answer, asking which Go version the service runs is itself a sign of maturity. For an older version, saying you would set the value by hand is a correct answer.

How do you test concurrent Go code?

You lean on three tools: the -race flag, tests with timeouts and deterministic time. The testing/synctest package, generally available since Go 1.25, runs a test inside an isolated bubble where time is virtual. As a result, tests that call time.Sleep finish instantly.

Also, rely on synchronisation points instead of sleep durations. For example, use a channel signal to know a goroutine has started, not a Sleep. This small change removes most flaky tests in CI.

  • Run every test at least once with go test -race.
  • Repeat flaky tests with the -count flag.
  • Use synctest or an injectable clock for time based logic.
  • Check that the goroutine count returns to its start value.

Do not judge performance without measuring it. I made the same point for websites in my guide to the Lighthouse performance test.

Which tasks come up in the live coding round?

The live coding round usually brings small tasks of 30 to 45 minutes; that range is my own observation, not a guarantee. The tasks I see most often are downloading URLs with limited concurrency, a request aggregator with a timeout and a thread safe cache.

  1. Restate the problem in your own words and ask about limits.
  2. Write the simplest version that works.
  3. Then add concurrency and show the cancel path.
  4. Finally, talk through race and leak risks out loud.

If you follow this order, you leave working code even when time runs out. On the other hand, a candidate who jumps straight to a clever solution often ends with half finished code. In short, the interviewer scores your thinking more than perfect code.

How should you plan your prep for golang interview questions?

For golang interview questions, a two week plan is a fair starting point for most candidates; this is a field based suggestion, not a guarantee. In week one you study language basics and concurrency concepts. Then in week two you move on to patterns and live coding.

DaysTopicOutput
1 to 3Slices, maps, interfaces, errorsA small CLI tool
4 to 7Goroutines, channels, select, syncA racy counter and its fix
8 to 10Context, worker pool, errgroupA bounded concurrent downloader
11 to 14Tests, race detector, pprof, mock interviewA small tested service

Keep every output in a GitHub repository, and you will have a real portfolio to show. Also, clear README files prove your written communication. If you need help with the non code side of a project, take a look at my web design service.

What mistakes do candidates make with golang interview questions?

The most common mistake I see with golang interview questions is trying to solve every problem with channels. Sometimes a plain Mutex is both easier to read and faster. The Go docs present the two tools as partners, not rivals. So the interviewer expects you to explain why you picked one.

  • Waiting with time.Sleep.
  • Closing a channel from the receiver side.
  • Forgetting to call cancel.
  • Starting unlimited goroutines and draining resources.
  • Never having run the race detector.

Another mistake is swallowing errors. Instead of logging an error inside a goroutine and moving on, pass it up with errgroup or a result channel. When the interviewer asks where the error goes, you need a clear answer.

Finally, prepare your CV and portfolio page with care. On my about page I explain plainly how I work, and I suggest you describe yourself just as plainly. If you have questions, reach me through the contact page.

Which official sources should you study?

The most reliable source is the Go team itself. Effective Go covers the core ideas in its concurrency section. The Go memory model gives the formal definition of synchronisation and feeds many senior level questions.

Also, the race detector article explains the limits of the tool. For version changes, read the Go 1.25 release notes and the Go blog post on loop variables. That way you speak with current facts you can verify.

#Golang#Go#Interview#Concurrency#Goroutines#Backend
Share:
Talha Aslan
Talha Aslan

Google Partner digital marketing expert. Hands-on with SEO, Google Ads, web design and e-commerce projects since 2012; every post here comes from that experience.

Next project

Let's talk about your project.

No middlemen, no layers: you talk directly to the expert doing the work. The first consultation is free, I listen to your goal and come back with a clear roadmap.

WhatsApp Call Now