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

Go backend interview questions: best practices explained

What teams ask senior Go engineers.

Expona Admin10 min read

Hook: Why interview preparation matters

When you're interviewing for a senior Go role at scale, technical interviewers aren't looking for textbook answers—they're probing how you've solved real production problems. Go backend interview questions: best practices explained covers the patterns that teams across e-commerce, SaaS, and ride-hailing use every day. These aren't trick questions; they're signals of experience.

How do you structure a service?

This question separates engineers who've shipped systems from those who've read blog posts. When an interviewer asks this, they want to understand whether you can grow a codebase without it becoming a mess of circular dependencies and god packages.

The best answer combines two ideas: package by feature, and explicit wiring in main. Here's what that looks like:

go
// cmd/api/main.go
package main

import (
	"github.com/yourorg/service/internal/auth"
	"github.com/yourorg/service/internal/db"
	"github.com/yourorg/service/internal/orders"
	"github.com/yourorg/service/internal/payments"
)

func main() {
	cfg := loadConfig()
	dbConn := db.Connect(cfg.DatabaseURL)
	
	authSvc := auth.NewService(dbConn)
	paymentSvc := payments.NewService(cfg.StripeKey)
	orderSvc := orders.NewService(dbConn, paymentSvc)
	
	// HTTP routes
	mux := setupRoutes(authSvc, orderSvc)
	server.ListenAndServe(":8080", mux)
}

Each package—auth, orders, payments—contains its own domain logic, database queries, and HTTP handlers. Dependencies flow down: orders depends on payments and db, but payments never imports orders.

The key insight interviewers look for: wiring happens in one place. When someone new joins your team and wants to understand how the service boots, they read main.go. They see all dependencies declared upfront. No hidden singletons. No init functions doing magic.

If your service also has background workers or migrations, wire them explicitly too:

go
func main() {
	cfg := loadConfig()
	dbConn := db.Connect(cfg.DatabaseURL)
	
	if cfg.RunMigrations {
		db.Migrate(dbConn)
	}
	
	authSvc := auth.NewService(dbConn)
	orderSvc := orders.NewService(dbConn, authSvc)
	
	// Wire HTTP server
	httpServer := setupHTTPServer(orderSvc)
	
	// Wire background workers
	emailWorker := orders.NewEmailWorker(dbConn)
	reconciler := payments.NewReconciler(dbConn)
	
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	
	go emailWorker.Start(ctx)
	go reconciler.Start(ctx)
	
	if err := httpServer.ListenAndServe(); err != nil {
		log.Fatalf("server error: %v", err)
	}
}

This tells interviewers you understand composition, you avoid hidden state, and you think about multiple execution paths (HTTP handlers, background jobs) from the start.

How do you handle cancellation?

This is where the conversation shifts from structure to reliability. Cancellation via context.Context is the Go answer to timeouts, request deadlines, and graceful shutdown.

The pattern is simple in theory: thread ctx through every function that does I/O or calls other services. In practice, most engineers miss edge cases.

go
// Good: context threaded through the call stack
func (s *OrderService) CreateOrder(ctx context.Context, req *CreateOrderReq) (*Order, error) {
	// Check if context is already cancelled before doing work
	if err := ctx.Err(); err != nil {
		return nil, fmt.Errorf("context cancelled: %w", err)
	}
	
	// Pass context to database layer
	order, err := s.db.InsertOrder(ctx, req)
	if err != nil {
		return nil, err
	}
	
	// Pass context to payment service
	if err := s.paymentSvc.Charge(ctx, order.ID, order.Total); err != nil {
		// Rollback or mark order as failed
		s.db.MarkOrderFailed(ctx, order.ID)
		return nil, err
	}
	
	return order, nil
}

// Database layer respects context deadlines
func (db *DB) InsertOrder(ctx context.Context, req *CreateOrderReq) (*Order, error) {
	// If context has a timeout, pass it to the query
	rows, err := db.conn.QueryContext(ctx, "INSERT INTO orders (...) RETURNING id", ...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	// ... scan and return
}

The most common mistake here: passing context.Background() when you should pass the request's context. That defeats cancellation entirely. If a client disconnects or a deadline passes, your query will still run to completion.

For long-running operations (batch jobs, workers), create a derived context that lives for the operation's lifetime:

go
// emailWorker processes notifications with a timeout per batch
func (w *EmailWorker) processBatch(parentCtx context.Context) error {
	// Create a child context with a 30-second timeout for this batch
	ctx, cancel := context.WithTimeout(parentCtx, 30*time.Second)
	defer cancel()
	
	emails, err := w.db.GetPendingEmails(ctx, 100)
	if err != nil {
		return err
	}
	
	for _, email := range emails {
		// Respect the batch timeout and parent cancellation
		if err := w.send(ctx, email); err != nil {
			w.logger.Errorf("failed to send %s: %v", email.ID, err)
			// Continue sending others, but don't block indefinitely
		}
	}
	
	return nil
}

Interviewers asking this question are checking whether you've debugged a goroutine leak or timeout issue in production. Your answer should reflect that lived experience: context isn't optional, and it's not just for HTTP handlers.

How do you find a memory leak?

This is where theory meets cold, hard reality. A production service consuming 2 GB yesterday but 8 GB today—that's the scenario you'll explain how to debug.

The answer: pprof heap profiles and goroutine dumps.

First, enable the pprof HTTP endpoint in your service:

go
import _ "net/http/pprof"

func main() {
	// Expose pprof on a separate port to keep it isolated from your API
	go func() {
		log.Println(http.ListenAndServe("localhost:6060", nil))
	}()
	
	// ... rest of your service
}

Now, capture heap profiles over time. Take one snapshot under normal load, let the service run for an hour, then take another:

go
// Script to capture and compare heap profiles
// curl http://localhost:6060/debug/pprof/heap > heap.before.prof
// [wait an hour with production traffic]
// curl http://localhost:6060/debug/pprof/heap > heap.after.prof
// go tool pprof -base heap.before.prof heap.after.prof

The -base flag shows you the delta—which allocations grew. Look for familiar code paths: are caches unbounded? Is there a map that grows without eviction? Is a goroutine leaking?

For goroutine leaks specifically, dump the goroutine count over time:

go
// Add this to your health check or metrics endpoint
func getGoroutineCount(w http.ResponseWriter, r *http.Request) {
	count := runtime.NumGoroutine()
	w.Header().Set("Content-Type", "text/plain")
	fmt.Fprintf(w, "%d\n", count)
}

// In your main
http.HandleFunc("/debug/goroutines", getGoroutineCount)

If goroutine count climbs monotonically, you're spawning goroutines that never exit. Use curl http://localhost:6060/debug/pprof/goroutine to see the stack traces. Look for goroutines stuck on channel sends or receives, or waiting on locks they'll never acquire.

A real example from production: a service spawned a goroutine for each incoming request to handle cancellation signaling. But if the request handler panicked before draining the signal, that goroutine lived forever. The fix was to ensure every goroutine has a clear exit condition and test it under cancellation.

Interviewers respect the answer that includes: "I'd take profiles before and after, compare them, then look at the code paths that grew." It shows you know the tools and the methodology.

How do you shut down gracefully?

Graceful shutdown is the final piece of production readiness. When your Kubernetes pod gets a SIGTERM, you have maybe 30 seconds to drain in-flight requests before the container dies.

The pattern: catch OS signals, shut down the HTTP server, wait for workers to finish, then exit.

go
func main() {
	// ... setup ...
	
	server := &http.Server{
		Addr:    ":8080",
		Handler: mux,
	}
	
	// Background goroutine to listen for signals
	sigChan := make(chan os.Signal, 1)
	signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
	
	go func() {
		sig := <-sigChan
		log.Printf("received signal: %v", sig)
		
		// Create a timeout for shutdown
		shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
		defer cancel()
		
		// Gracefully shut down the HTTP server
		// This stops accepting new connections and waits for in-flight requests
		if err := server.Shutdown(shutdownCtx); err != nil {
			log.Printf("shutdown error: %v", err)
		}
	}()
	
	if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
		log.Fatalf("server error: %v", err)
	}
	
	log.Println("server stopped")
}

But HTTP graceful shutdown isn't enough if you have background workers. You need to coordinate them too:

go
func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	
	// Start workers with the root context
	emailWorker := orders.NewEmailWorker(db)
	reconciler := payments.NewReconciler(db)
	
	go emailWorker.Start(ctx)
	go reconciler.Start(ctx)
	
	server := &http.Server{Addr: ":8080", Handler: mux}
	
	sigChan := make(chan os.Signal, 1)
	signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
	
	go func() {
		<-sigChan
		log.Println("shutting down...")
		
		// Signal all workers to stop
		cancel()
		
		// Give HTTP server 20 seconds, workers another 10 to finish
		shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 20*time.Second)
		defer shutdownCancel()
		
		if err := server.Shutdown(shutdownCtx); err != nil {
			log.Printf("http shutdown error: %v", err)
		}
		
		// Wait for workers to gracefully exit (they respect ctx cancellation)
		time.Sleep(10 * time.Second)
	}()
	
	if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
		log.Fatalf("server error: %v", err)
	}
}

The key details interviewers listen for: you cancel the context so workers stop accepting new work, you give the HTTP server time to drain, and you don't force-kill goroutines. You trust the cancellation signal propagates.

Common mistakes

  • Hiding dependencies: Wiring services in init functions or relying on global singletons makes it impossible to understand your service's startup. Always wire in main.
  • Ignoring context cancellation: Passing context.Background() to every function defeats timeouts and graceful shutdown. Thread the request context through your call stack.
  • Missing goroutine bounds: Spawning unbounded goroutines per request or event is a fast path to memory exhaustion. Always cap concurrency or use worker pools.
  • Not testing shutdown: Graceful shutdown code is untested in most codebases. Write a test that sends SIGTERM and verifies in-flight requests complete and workers exit cleanly.
  • Assuming pprof is always available: If you only enable pprof in development, you can't debug memory issues in production. Expose it on a separate port but enable it everywhere.

Key takeaways

  • Structure by feature, wire dependencies explicitly in main. This makes your service legible and testable.
  • Thread context.Context through every I/O operation. Cancellation and timeouts are not optional in production.
  • Debug memory leaks with pprof heap profiles and goroutine dumps. Take snapshots over time and compare deltas.
  • Implement graceful shutdown by catching OS signals, shutting down the HTTP server, and cancelling the root context so workers exit.
  • Test your shutdown path. In production, ungraceful exits mean lost requests and data inconsistency.
  • Interviewers ask these questions because they've suffered the consequences of getting them wrong. Your answers should reflect lived experience, not just theory.
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