What I Wish I Knew Before Writing My First Go Service
Go is a small language. The specification fits on one page. You can learn the syntax in a weekend and feel productive on Monday.
Then you write a real service and discover that knowing the syntax isn’t the same as knowing the idioms. These are the things I had to learn the hard way.
context Is Not Optional
Most Go codebases enforce one rule above all others: the first argument of any function that does I/O is ctx context.Context. This isn’t just convention. It’s the mechanism that propagates deadlines, cancellation, and shutdown signals down the entire call stack without explicit threading.
func (s *Server) fetchUser(ctx context.Context, id int64) (*User, error) {
row := s.db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = ?", id)
// ...
}
QueryRowContext respects the context’s deadline. If the HTTP request is cancelled by the client, the in-flight database query is cancelled too — for free, as long as the context got passed through.
The mistake I made first: creating context.Background() inside functions that had been called with a real context. That severs the propagation chain. When a handler gets cancelled, any work it spawned with a fresh background context keeps running.
The rule is simple: accept a context as the first argument in any function that touches I/O. Pass it down. Never create a background context inside a function that was called with one.
Wrap Errors Properly
Go 1.13 added %w for error wrapping. If you’re not using it, you’re losing information.
if err != nil {
return fmt.Errorf("fetching user %d: %w", id, err)
}
This creates a chain. By the time the error reaches your HTTP handler, the message reads something like handling request: fetching user 42: sql: no rows in result set. Every layer adds context. You can read a log line and know exactly where it came from.
errors.Is walks the chain automatically:
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "not found", http.StatusNotFound)
return
}
The important thing: wrapping with %v instead of %w loses the original error type. errors.Is will return false. This burned me on a production bug where a sql.ErrNoRows was being wrapped with %v and the not-found check never triggered.
For domain errors, define sentinel values or typed errors:
var ErrNotFound = errors.New("not found")
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
Then errors.As to unwrap at the boundary:
var ve *ValidationError
if errors.As(err, &ve) {
http.Error(w, ve.Message, http.StatusBadRequest)
return
}
Graceful Shutdown
A process that gets killed mid-request is a bad service. Go makes graceful shutdown straightforward, but nothing wires it up for you.
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
srv := &http.Server{
Addr: ":8080",
Handler: routes(),
}
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
<-ctx.Done() // block until signal received
stop() // release signal handling so a second Ctrl+C hard-kills immediately
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown: %v", err)
}
}
signal.NotifyContext converts OS signals into context cancellation. srv.Shutdown stops accepting new connections and waits for in-flight requests to finish, up to the deadline.
The 10-second timeout is deliberate. Without a deadline on Shutdown, a stuck handler blocks the process from exiting. In a container environment that means Kubernetes eventually sends SIGKILL and you’re back to square one.
Structured Logging with slog
Go 1.21 added log/slog to the standard library. Before that, every team had a different opinion about zap vs zerolog vs logrus. Now there’s a good-enough default.
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
Then log with key-value pairs:
slog.Info("request completed",
"method", r.Method,
"path", r.URL.Path,
"status", status,
"ms", time.Since(start).Milliseconds(),
)
JSON output, structured fields, compatible with any log aggregation pipeline. The old log.Printf("handled %s %s in %dms", ...) produces a string you have to parse to query.
If you need per-request fields like a trace ID, thread the logger through context:
type loggerKey struct{}
func withLogger(ctx context.Context, l *slog.Logger) context.Context {
return context.WithValue(ctx, loggerKey{}, l)
}
func loggerFrom(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(loggerKey{}).(*slog.Logger); ok {
return l
}
return slog.Default()
}
Middleware assigns a logger with the request’s trace ID attached; everything downstream calls loggerFrom(ctx) and gets structured fields for free.
You Probably Don’t Need a Framework
Coming from Express or FastAPI, the first instinct is to find the Go equivalent. There isn’t a dominant one, and for most services you don’t need one.
Go 1.22 added method and path-parameter routing to net/http’s ServeMux:
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", handleGetUser)
mux.HandleFunc("POST /users", handleCreateUser)
Middleware is a function that wraps a handler:
func withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
slog.Info("request",
"path", r.URL.Path,
"ms", time.Since(start).Milliseconds(),
)
})
}
Stack them:
handler := withLogging(withAuth(withRecovery(mux)))
This is the same pattern every router library uses under the hood. They add syntactic sugar; the shape is identical.
For genuinely complex routing — nested groups, OpenAPI generation, per-route middleware — chi or huma are reasonable. But reach for them after the stdlib proves insufficient, not before.
Goroutines Need Exit Conditions
Easy to spawn, easy to leak.
// This goroutine runs forever — no way to stop it
go func() {
for {
doWork()
time.Sleep(interval)
}
}()
A goroutine that can’t be stopped is a resource that can’t be reclaimed. Under graceful shutdown, your process will wait for it indefinitely (or get killed).
The pattern that fixes it:
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
doWork(ctx)
}
}
}()
The select with ctx.Done() means the goroutine exits when the context is cancelled. Every goroutine you spawn should have a clear, reachable exit.
Track them with sync.WaitGroup so shutdown can wait for them to finish:
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
runWorker(ctx)
}()
// In shutdown, after cancelling ctx:
wg.Wait()
None of this is in the language spec. Most of it isn’t even in the official tour. It’s the layer of conventions that experienced Go developers apply automatically and that everyone else figures out through production incidents.
The good news: once the patterns are internalized, Go becomes extremely predictable. You know where the context is, you know how errors flow, you know what happens at shutdown. That predictability is what makes it worth the learning curve.