Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 078 / 100

Go interview questions: a practical guide for developers

Questions about Go fundamentals.

Expona Admin8 min read

Go interview questions: a practical guide for developers

If you're preparing for a Go role or conducting interviews, you'll notice that Go interview questions tend to focus on concurrency, type systems, and runtime behavior rather than syntax mechanics. We've seen these topics trip up even experienced developers who haven't internalized Go's design philosophy. This guide walks through the core Go fundamentals that consistently appear in technical interviews, with practical examples drawn from production systems we've built.

Goroutines vs threads: Runtime-scheduled, cheap, multiplexed on OS threads

One of the first Go interview questions you'll encounter asks how goroutines differ from operating system threads. The answer matters because it shapes how you design concurrent systems.

Goroutines are lightweight user-space threads managed by the Go runtime, not the OS kernel. A single OS thread can multiplex thousands of goroutines. The runtime maintains its own scheduler that decides when to swap goroutines in and out—a concept known as M:N scheduling, where M goroutines run on N OS threads.

Compare this to traditional threads: spawning an OS thread costs megabytes of memory for its stack and involves kernel context switches. Spawning a goroutine costs kilobytes and involves only a runtime reschedule. In production e-commerce systems, we've launched millions of goroutines to handle concurrent requests; attempting that with OS threads would exhaust memory immediately.

Practical example: Imagine handling 10,000 concurrent database queries. With threads, you'd need 10,000 threads consuming tens of gigabytes. With goroutines, you'd use a handful of OS threads and let the runtime schedule 10,000 goroutines on them.

go
package main

import (
	"fmt"
	"time"
)

func worker(id int) {
	for i := 0; i < 3; i++ {
		fmt.Printf("Worker %d, iteration %d\n", id, i)
		time.Sleep(100 * time.Millisecond)
	}
}

func main() {
	// Launch 1000 goroutines; try this with OS threads
	for i := 0; i < 1000; i++ {
		go worker(i)
	}
	time.Sleep(1 * time.Second)
}

In this snippet, spawning 1000 goroutines is trivial and fast. The runtime handles scheduling them onto a small number of OS threads. The key insight: goroutines enable you to write concurrent code that looks sequential, without worrying about thread pools or manual scheduling.

Buffered vs unbuffered channels: Synchronization vs queuing

Channels are Go's primary concurrency primitive, and understanding buffered versus unbuffered channels is essential for writing correct, deadlock-free code.

An unbuffered channel requires both sender and receiver to be ready at the same instant. The sender blocks until a receiver is waiting, and the receiver blocks until a sender has a value. This enforces synchronization: both parties rendezvous and exchange data atomically.

A buffered channel holds a fixed number of values. The sender only blocks when the buffer is full; the receiver only blocks when the buffer is empty. This decouples sender and receiver, allowing asynchronous work patterns.

Real-world scenario: In our ride-hailing backend, we use unbuffered channels to coordinate order assignment between the matcher and driver service—both must be ready to exchange the order detail synchronously. But we use buffered channels for logging: workers write log events to a channel, a background goroutine drains it, and neither waits for the other.

go
package main

import (
	"fmt"
)

func unbufferedExample() {
	// Unbuffered channel: sender and receiver must be ready
	ch := make(chan string)
	
	go func() {
		ch <- "hello"
	}()
	
	// This read blocks until goroutine sends
	msg := <-ch
	fmt.Println("Unbuffered:", msg)
}

func bufferedExample() {
	// Buffered channel: sender can queue without receiver ready
	ch := make(chan string, 2)
	
	ch <- "first"
	ch <- "second"
	// Sender does not block; buffer has space
	
	fmt.Println("Buffered:", <-ch)
	fmt.Println("Buffered:", <-ch)
}

func main() {
	unbufferedExample()
	bufferedExample()
}

A common mistake is creating a buffered channel that's never drained, leading to goroutine leaks. Always pair a sender with a receiver or explicitly close channels when done.

How do interfaces work: Implicit satisfaction and interface values

Go's interface system is radically different from most languages. There are no explicit "implements" declarations. A type satisfies an interface if it implements all the required methods—period. This implicit satisfaction is powerful but can confuse developers from Java or C# backgrounds.

An interface value is not just a pointer or reference; it's a runtime pair: a type descriptor and a concrete value. When you assign a concrete value to an interface variable, Go wraps both the type and the value inside.

Example from a CRM backend: You define an interface Logger with a Log method. You create a FileLogger struct with that method. You never tell Go that FileLogger implements Logger—it just does. Later, you can pass a FileLogger to any function expecting a Logger.

go
package main

import (
	"fmt"
)

type Logger interface {
	Log(msg string)
}

type FileLogger struct {
	name string
}

// FileLogger implicitly satisfies Logger
func (fl *FileLogger) Log(msg string) {
	fmt.Printf("[%s] %s\n", fl.name, msg)
}

type ConsoleLogger struct{}

// ConsoleLogger also satisfies Logger
func (cl *ConsoleLogger) Log(msg string) {
	fmt.Printf("CONSOLE: %s\n", msg)
}

func printLogs(logger Logger, messages []string) {
	for _, msg := range messages {
		logger.Log(msg)
	}
}

func main() {
	fl := &FileLogger{name: "app.log"}
	cl := &ConsoleLogger{}
	
	printLogs(fl, []string{"error1", "error2"})
	printLogs(cl, []string{"warning1"})
}

The power: you can define the Logger interface in your logging package, and users can define their own types that satisfy it without importing or modifying your code. This enables dependency inversion and makes testing easier—just create a mock that satisfies the interface.

The nil interface gotcha: An interface holding a nil pointer is not nil

This is the most insidious Go fundamentals trap. A variable of interface type is nil only if both its type and value are nil. If it holds a nil pointer to a concrete type, the interface itself is not nil—it's nil-typed.

Production incident: We had a function that checked if logger == nil before using it. A caller passed a *FileLogger(nil)—a nil pointer to a FileLogger. The interface wasn't nil, so the check passed, and we crashed when trying to call methods on the nil pointer.

go
package main

import (
	"fmt"
)

type Logger interface {
	Log(msg string)
}

type FileLogger struct{}

func (fl *FileLogger) Log(msg string) {
	fmt.Println(msg)
}

func safeLog(logger Logger) {
	// This check is WRONG if logger holds a nil pointer
	if logger == nil {
		fmt.Println("Logger is nil, skipping")
		return
	}
	logger.Log("message")
}

func main() {
	var fl *FileLogger = nil
	var logger Logger = fl // Interface holds nil pointer
	
	fmt.Printf("logger == nil: %v\n", logger == nil) // false!
	fmt.Printf("logger is: %T\n", logger) // *main.FileLogger
	
	// This will panic: nil pointer dereference
	safeLog(logger)
}

The fix: use the comma-ok idiom to check both type and value, or validate at the point of assignment:

go
func safeLog(logger Logger) {
	// Check if logger is nil OR its value is nil
	if logger == nil {
		return
	}
	
	// For a pointer receiver, also check the pointer itself
	if l, ok := logger.(*FileLogger); ok && l == nil {
		return
	}
	
	logger.Log("message")
}

Better practice: use interfaces that don't require nil checks. Make zero-value instances usable, or fail loudly at initialization.

Common mistakes

  • Leaking goroutines: Launch a goroutine but never signal it to stop. The goroutine blocks indefinitely, consuming memory. Use context cancellation or explicit channels to coordinate shutdown.
  • Sending on closed channels: Go will panic if you send on a closed channel. Close channels from the sender side only, or use sync.Once to ensure it happens once.
  • Race conditions with maps: Maps are not thread-safe. Concurrent reads and writes cause data corruption. Guard maps with sync.Mutex or use sync.Map for simple key-value patterns.
  • Assuming interface methods are nil-safe: If an interface holds a nil pointer, calling methods on it will panic. Always validate or document whether nil is a valid state.
  • Buffered channel sizes chosen without thought: A buffer of 1 when you need 10 causes unnecessary blocking. A buffer of 1000 when you need 1 wastes memory. Profile and measure.

Key takeaways

  • Goroutines are lightweight user-space threads multiplexed by the Go runtime onto OS threads. Launch thousands without hesitation.
  • Unbuffered channels synchronize; buffered channels queue. Choose based on whether you need sender and receiver to rendezvous or work asynchronously.
  • Interfaces in Go are satisfied implicitly by matching method signatures. An interface value holds both a type descriptor and a concrete value.
  • An interface variable is nil only if both its type and value are nil. Holding a nil pointer does not make the interface nil—this is a frequent source of panics.
  • The combination of goroutines, channels, and interfaces makes concurrent, maintainable code possible. Master these three, and you'll write production Go that scales.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles