Back to Writing
01 — Engineering
Getting Started with Go · Part 5 of 5

Getting Started with Go, Part 5: Production Go

Cyrus Kiprop·August 31, 2026·6 min read

Four parts in, the job processor is concurrent, tested, and generic where it needed to be. None of that makes it production-ready — it still can't tell you where its time goes, it can't shut down without dropping in-flight work, and it prints to stdout instead of emitting anything a log pipeline could use. This last part closes those three gaps.

Profiling: measure before you optimize

Go ships its profiler in the standard library, not as a bolted-on tool — runtime/pprof for capturing profiles programmatically, net/http/pprof for exposing them over HTTP on a running service (import it for its side effect and pprof registers its own routes on the default mux):

go
import _ "net/http/pprof"

// elsewhere, once, in a real service:
go http.ListenAndServe("localhost:6060", nil)
// then: go tool pprof http://localhost:6060/debug/pprof/profile

That's the shape you'd actually run in production — a debug-only port serving live profiles you can pull with go tool pprof whenever something looks slow. It needs a real bound port and a real HTTP client hitting it, so it's not something a sandboxed "Run" button can demonstrate honestly. What is honestly runnable is capturing a CPU profile programmatically, in-process, with no server involved:

go
package main

import (
	"bytes"
	"fmt"
	"runtime/pprof"
)

func main() {
	var buf bytes.Buffer
	if err := pprof.StartCPUProfile(&buf); err != nil {
		fmt.Println("could not start profile:", err)
		return
	}

	total := 0
	for i := 0; i < 5_000_000; i++ {
		total += i * i
	}

	pprof.StopCPUProfile()
	fmt.Printf("total: %d\n", total)
	fmt.Printf("captured a %d-byte CPU profile\n", buf.Len())
}

In a real program you'd write buf to a file and hand it to go tool pprof for the flame graph; here the byte count is just proof the capture actually ran. The rule that matters more than the tool: profile before you optimize. "This feels slow" and "this is where the CPU time actually goes" are frequently different lines of code, and pprof is how you stop guessing.

Memory: fewer surprises than you'd expect, if you watch allocations

Go's garbage collector is concurrent and mostly invisible day-to-day, but allocation-heavy hot paths still cost you — every append that outgrows a slice's capacity, every value that escapes to the heap instead of staying on the stack, is a GC-tracked object. runtime.MemStats gives you a coarse but real read on allocation counts:

go
package main

import (
	"fmt"
	"runtime"
)

func main() {
	var before runtime.MemStats
	runtime.ReadMemStats(&before)

	jobs := make([]string, 0)
	for i := 0; i < 200000; i++ {
		jobs = append(jobs, fmt.Sprintf("job-%d", i))
	}

	var after runtime.MemStats
	runtime.ReadMemStats(&after)

	fmt.Printf("processed %d jobs\n", len(jobs))
	fmt.Printf("heap objects allocated: %d\n", after.Mallocs-before.Mallocs)
}

Run it, then try changing make([]string, 0) to make([]string, 0, 200000) — pre-sizing the slice to the capacity you already know you need — and watch the malloc count drop noticeably. That's the whole habit: know roughly how big a slice or map is going to get, and pass that as the initial capacity. It's a small change with a real, measurable effect, and it's one of the few "production" habits that costs you nothing in readability.

Graceful shutdown

A service that exits the instant it's told to stop drops whatever it was mid-processing. The fix is the same context cancellation from Part 3, wired to something that can decide when to cancel — in a real service that's signal.Notify listening for SIGTERM, so your process gets a chance to finish in-flight work before the platform kills it outright:

go
ctx, stop := context.WithCancel(context.Background())
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
go func() {
    <-sigCh
    stop() // cancels ctx — every worker checking ctx.Done() starts winding down
}()

A sandboxed run has no real signal to deliver, so the runnable version below stands in a fixed timeout for that external trigger — the shutdown logic on the receiving end is identical either way, which is the part actually worth internalizing:

go
package main

import (
	"context"
	"fmt"
	"log/slog"
	"os"
	"time"
)

func worker(ctx context.Context, id int, jobs <-chan string, log *slog.Logger) {
	for {
		select {
		case <-ctx.Done():
			log.Info("worker stopping", "worker", id, "reason", ctx.Err())
			return
		case job, ok := <-jobs:
			if !ok {
				log.Info("worker done, channel closed", "worker", id)
				return
			}
			log.Info("processed job", "worker", id, "job", job)
			time.Sleep(5 * time.Millisecond)
		}
	}
}

func main() {
	log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))

	// Stands in for signal.Notify(sigCh, syscall.SIGTERM) in a real service —
	// same ctx.Done() shutdown path either way.
	ctx, stop := context.WithTimeout(context.Background(), 30*time.Millisecond)
	defer stop()

	jobs := make(chan string)
	go func() {
		for i := 0; i < 20; i++ {
			jobs <- fmt.Sprintf("job-%d", i)
		}
		close(jobs)
	}()

	done := make(chan struct{})
	go func() {
		worker(ctx, 1, jobs, log)
		close(done)
	}()

	<-done
	log.Info("shutdown complete")
}

Run it and you'll see the worker process several jobs, then stop cleanly with reason="context deadline exceeded" instead of getting cut off mid-list — that's the entire behavior a real SIGTERM handler buys you, demonstrated without needing an actual signal. (The timestamps in the output will show November 2009 — that's the Playground's frozen sandbox clock, a known quirk, not a bug in the code.)

Structured logging over fmt.Println

Every earlier part in this series used fmt.Println for output, which was the right call while we were learning syntax — it's the fastest way to see what's happening. It's the wrong call in a real service, because fmt.Println("processed job", name) produces a line a human can read and a log pipeline can't reliably parse. log/slog, in the standard library since Go 1.21, gives you structured key-value output instead:

go
log := slog.New(slog.NewTextHandler(os.Stdout, nil))
log.Info("processed job", "worker", id, "job", jobName, "duration_ms", 12)
// time=... level=INFO msg="processed job" worker=1 job=job-0 duration_ms=12

Swap NewTextHandler for NewJSONHandler and the exact same call emits JSON instead — one line, no change to every call site, because the fields you log don't change, only how they're serialized. That's the entire pitch: attach structured fields as you go, decide the output format once, at the edge.

What's still missing

This series' job processor is concurrent, tested, generic where it earns it, profilable, and shuts down cleanly — and it's still not a production service. It has no configuration management beyond hardcoded values, no metrics beyond what pprof can tell you after the fact, no retries with backoff, no persistence if the process dies mid-job. Those aren't Go-specific gaps; they're the same gaps every language's toy project has on the way to being a real one. The difference this series was actually about is narrower and more useful than "how do you run a real service": it's that Go gives you goroutines, channels, context, testing, generics, and pprof as load-bearing parts of the standard library, not a pile of optional third-party choices you have to evaluate before you can even start.