When you need numbers, not guesses
You deploy a Go service. It handles requests smoothly for a week. Then response times spike. You do not know if the database is timing out, a memory leak is filling the heap, or a sudden traffic surge is overwhelming the workers. You need numbers, not guesses. Go ships with a built-in way to expose runtime metrics without adding a single third-party dependency. The package is called expvar.
How exported variables work
expvar stands for exported variables. You create a variable in your code, attach a name to it, and the package automatically registers an HTTP handler at /debug/vars. That handler returns a JSON object containing every registered variable. The package handles concurrency safely. You update the variable from any goroutine, and the JSON endpoint reflects the latest value without race conditions.
Think of it like a whiteboard in a control room. Engineers walk up, write the latest temperature or pressure reading, and step away. A camera streams the whiteboard to a monitor. You do not need to configure the camera or write a custom streaming protocol. The board just updates, and anyone with access to the monitor sees the current state.
Go handles the registration through an init() function. When you import "expvar", the package runs its initialization code exactly once. That code attaches a handler to http.DefaultServeMux. This is a standard Go convention: packages that need to register themselves globally do it in init(). You never call the registration function manually. You just import the package and the plumbing appears.
Convention aside: Go relies heavily on http.DefaultServeMux for quick prototypes. In production, you usually create a custom http.ServeMux and pass it to http.Server. If you do that, expvar will not automatically attach. You must call expvar.Handler() and mount it yourself. The community expects you to manage your own mux in long-running services.
The minimal setup
Here is the simplest way to track a counter. You define the variable, update it, and start a server.
package main
import (
"expvar"
"net/http"
)
// requests tracks how many times the counter has been incremented.
var requests = expvar.NewInt("requests")
// main starts the HTTP server and updates the metric.
func main() {
// Increment the atomic counter safely from the main goroutine.
requests.Add(1)
// Start the server on port 8080 using the default mux.
// The default mux already contains the /debug/vars handler.
http.ListenAndServe(":8080", nil)
}
When you run this program, the import statement triggers the init() sequence. The expvar package registers its handler on the default multiplexer. expvar.NewInt allocates a thread-safe counter backed by atomic operations. Calling Add(1) updates the underlying value without locks. The server starts and listens. If you visit http://localhost:8080/debug/vars, the handler marshals the internal map of variables into JSON and writes it to the response body. The output looks like a simple key-value pair. No extra configuration is required.
The atomic backing means you can call Add() from multiple goroutines simultaneously. The Go runtime guarantees that the counter never corrupts or loses an update. You do not need to wrap the call in a mutex or a channel. The package abstracts the synchronization away. The Value() method reads the current state using the same atomic load instruction. This design keeps the hot path fast while guaranteeing consistency.
Section closer: Atomic counters are fast. Let the runtime handle the synchronization.
Tracking real service behavior
Real services need more than a single counter. You usually want to track request counts, error rates, and maybe a custom calculation that runs on demand. expvar provides Map for nested metrics and Func for lazy evaluation.
Here is how you structure the initialization phase. You create a map, populate it with counters, and attach a derived metric.
package main
import (
"expvar"
"net/http"
)
// metrics holds the nested counters for the service.
var metrics = expvar.NewMap("service")
// init registers the counters inside the map before main runs.
func init() {
// SetInt creates a thread-safe counter inside the map.
metrics.Set("requests", expvar.NewInt("requests"))
metrics.Set("errors", expvar.NewInt("errors"))
// Func evaluates a closure every time the endpoint is hit.
// This avoids storing stale calculated values in memory.
metrics.Set("error_ratio", expvar.Func(func() interface{} {
reqs := metrics.Get("requests").(*expvar.Int)
errs := metrics.Get("errors").(*expvar.Int)
if reqs.Value() == 0 {
return 0.0
}
return float64(errs.Value()) / float64(reqs.Value())
}))
}
The init() function runs before main(). It populates the service map with two counters and one function. The Func type does not store a value. It stores a closure. Every time a client requests /debug/vars, the handler executes the closure and includes the result in the JSON payload. This pattern is useful for derived metrics like error ratios, uptime percentages, or queue depths. You avoid the overhead of updating a separate counter on every request.
Here is how you wire the handler and start the server. You cast the map entries back to their concrete types and update them during request processing.
func main() {
http.HandleFunc("/work", func(w http.ResponseWriter, r *http.Request) {
// Increment the request counter atomically.
metrics.Get("requests").(*expvar.Int).Add(1)
// Simulate a random failure to track error rates.
if r.URL.Query().Get("fail") == "true" {
metrics.Get("errors").(*expvar.Int).Add(1)
http.Error(w, "simulated failure", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
})
// Start the server with the default mux.
http.ListenAndServe(":8080", nil)
}
The type assertion is safe because init() guarantees the types. The closure inside Func performs the same assertion and calculates the ratio. Division by zero is guarded by a simple check. The JSON output nests everything under the service key, keeping the root level clean. You can chain maps to create hierarchical namespaces without writing custom serialization logic.
Convention aside: Go functions that take a context should respect cancellation and deadlines. expvar.Func does not receive a context. If your calculation involves network calls or database queries, keep it lightweight. Long-running closures will block the /debug/vars endpoint and slow down your monitoring dashboard.
Reading the JSON output
The /debug/vars endpoint returns a single JSON object. Every registered variable becomes a top-level key. Maps become nested objects. Functions become their evaluated result. The structure looks like this:
{
"cmdline": ["./myapp"],
"memstats": {...},
"service": {
"error_ratio": 0.15,
"errors": 15,
"requests": 100
}
}
The cmdline and memstats keys are registered automatically by the expvar package. They expose the process arguments and runtime memory statistics. You do not need to define them. The handler marshals the entire map on every request. This means the JSON payload grows with every variable you register. If you attach hundreds of counters, the response body becomes large and the marshaling step takes longer. Keep the namespace flat and focused.
Section closer: JSON is easy to parse. Keep the payload small and the keys predictable.
Where the package falls short
expvar is convenient, but it is not a full observability platform. The JSON format is flat and lacks native support for histograms, percentiles, or time-series aggregation. If you need to track response time distributions, you will have to approximate them with multiple counters or switch to a dedicated metrics library.
High-frequency updates can also trigger garbage collection pressure. expvar.Map stores pointers to variable objects. Rapidly creating and discarding temporary objects inside a Func closure will allocate memory on the heap. The garbage collector will run more often to reclaim it. For counters that increment thousands of times per second, stick to expvar.Int or expvar.Float. Avoid allocating inside the hot path.
Security is another consideration. The /debug/vars endpoint exposes internal state to anyone who can reach the port. If you deploy behind a public load balancer, you must restrict access. The compiler will not stop you from exposing it. You get undefined: expvar if you forget the import, but the runtime will happily serve metrics to the internet if you mount the handler on a public mux. Keep debugging endpoints behind authentication or internal network rules.
If you try to pass a non-atomic type to expvar, the compiler rejects the program with a type mismatch error. The package only accepts its own types. You cannot register a plain int or a custom struct. You must wrap the value in expvar.NewInt, expvar.NewFloat, or expvar.NewMap. The compiler complains with cannot use x (untyped int constant) as expvar.Var value in argument if you skip the wrapper.
Section closer: Debug endpoints are internal plumbing. Lock the door before you deploy.
Picking the right tool
Go gives you several ways to track application behavior. The right choice depends on your deployment environment and how you plan to consume the data.
Use expvar when you need zero-config metrics for a small service or a quick debugging session. Use expvar when you want to avoid third-party dependencies and only need counters or simple gauges. Use the prometheus/client_golang library when you need histograms, custom labels, and native integration with Prometheus dashboards. Use structured logging with trace IDs when you need to correlate individual request lifecycles across multiple services. Use OpenTelemetry when you require vendor-neutral tracing, metrics, and logs that ship to multiple backends simultaneously. Use plain sequential code when you do not need concurrency: the simplest thing that works is usually the right thing.
Section closer: Metrics should answer questions, not create them. Pick the tool that matches your query.