A window into the running process
You deployed a Go service. It's handling requests. You want to know how many requests it processed, or how many errors occurred, without adding a heavy monitoring stack or restarting the app. You need a window into the running process. Go ships with a built-in tool for this: expvar. It exposes variables as JSON over HTTP. No external dependencies. No configuration files. Just import, register, and read.
Think of expvar as a dashboard built into the standard library. It's like a car's instrument cluster. The engine runs, the speedometer ticks up, the fuel gauge drops. You don't need to open the hood to see the speed. expvar gives you a /debug/vars endpoint. You hit it with a browser or curl, and you get back a JSON object with every registered variable. The package manages the HTTP handler for you. You just define the variables and update them.
Minimal counter
Here's the smallest working server with a counter. The expvar package registers the /debug/vars endpoint automatically when you import it. You don't need to wire up a handler.
package main
import (
"expvar"
"fmt"
"net/http"
)
// counter tracks the number of times the handler runs.
// Package-level variables are required for expvar to publish them.
var counter = expvar.NewInt("counter")
// Handler increments the counter and prints a message.
func handler(w http.ResponseWriter, r *http.Request) {
counter.Add(1) // Increment the atomic counter safely.
fmt.Fprintf(w, "Count: %d", counter.Value()) // Return current value.
}
func main() {
http.HandleFunc("/", handler)
// expvar registers /debug/vars automatically via its init function.
fmt.Println("Server running on :8080")
http.ListenAndServe(":8080", nil)
}
Run this code and visit http://localhost:8080/debug/vars. You'll see a JSON object. The counter key holds the current value. Hit the root URL a few times, then refresh the vars page. The number updates.
The mechanics: atomic, automatic, and reflective
expvar relies on three behaviors that make it easy to use but also create specific constraints.
The variables are atomic. expvar.Int, expvar.Float, and expvar.String use atomic operations internally. You can call Add or Set from multiple goroutines simultaneously without locks. The updates are thread-safe. This is why expvar works well in HTTP handlers where requests arrive concurrently.
The registration is automatic. When you import expvar, the package's init function runs. It calls http.HandleFunc("/debug/vars", expvar.Handler()). This means any HTTP server you start will serve this endpoint. You don't need to mount the handler manually. The endpoint is always there if you import the package.
The discovery is reflective. expvar scans the package for package-level variables that implement the expvar.Var interface. Only variables declared at the package level appear in the JSON output. If you create a variable inside a function, it won't show up. The compiler won't warn you. You get a silent failure. This catches developers who try to create metrics inside main or a handler.
Local variables vanish. Keep metrics at the top level.
Grouping metrics with maps
Real apps need more than a single counter. You often want to group metrics, like response codes or queue depths. expvar.Map lets you nest variables. The map keys become nested objects in the JSON. You still need to type-assert the result of Get to update the value.
Here's a handler that tracks status codes using a map. The init function sets up the counters so they exist before any requests arrive.
package main
import (
"expvar"
"net/http"
)
// statusCodes tracks HTTP response codes grouped by status.
var statusCodes = expvar.NewMap("status_codes")
// init registers counters for common status codes.
// init runs before main, ensuring keys exist before requests hit.
func init() {
statusCodes.Set("200", expvar.NewInt("200"))
statusCodes.Set("500", expvar.NewInt("500"))
}
// Handler processes requests and updates metrics.
func handler(w http.ResponseWriter, r *http.Request) {
// Type-assert to *expvar.Int to access the Add method.
statusCodes.Get("200").(*expvar.Int).Add(1)
if r.URL.Path == "/fail" {
// Increment 500 counter and return error.
statusCodes.Get("500").(*expvar.Int).Add(1)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func main() {
http.HandleFunc("/", handler)
http.ListenAndServe(":8080", nil)
}
The JSON output now has a status_codes object containing 200 and 500. You can add more keys dynamically, but you must handle the case where a key doesn't exist. Get returns nil for missing keys. Dereferencing nil causes a panic. Always check for nil or ensure keys are pre-registered.
If you skip the type assertion, the compiler rejects the code with a method not found error because the interface doesn't expose Add. The assertion tells the compiler the value is an Int.
Pitfalls and silent failures
expvar is simple, but it has traps.
Duplicate names break JSON. If you register two variables with the same name, the JSON output contains duplicate keys. JSON parsers often fail on this. The compiler won't warn you because the name is just a string argument. You have to manage uniqueness yourself. Use a naming convention, like prefixing with the service name or module.
Package-level requirement. As mentioned, variables must be package-level. If you assign a variable inside a function, it won't appear in the output. You get a silent failure. The variable exists in memory, but expvar ignores it. This is the most common mistake.
Type assertion panics. When using maps, Get returns an interface{}. You must type-assert to the concrete type. If the key is missing, Get returns nil. Asserting nil to a pointer type returns nil. Calling a method on nil panics. Check for nil before asserting, or pre-register all keys in init.
Memory stats are included. expvar automatically includes a memstats key with runtime memory usage. You don't need to register it. It shows heap allocations, stack usage, and GC stats. This is useful for quick profiling without extra tools.
Names become JSON keys. The convention is lowercase with underscores. Avoid spaces or special characters. JSON parsers handle alphanumeric and underscores best. Stick to simple names.
Duplicate names break JSON. Name your variables carefully.
When to use expvar
expvar fills a specific niche. It's built-in, zero-dependency, and requires no configuration. It's perfect for quick debugging or small internal services where setting up a monitoring stack is overkill.
Use expvar when you need zero-dependency metrics for debugging or small internal services.
Use expvar when you want a quick JSON endpoint without configuring a monitoring stack.
Use expvar when you need to expose runtime memory stats alongside custom counters.
Reach for Prometheus client libraries when you need histograms, summaries, or integration with external dashboards.
Reach for OpenTelemetry when you need distributed tracing alongside metrics across multiple services.
Use slog when you need structured logging for events and errors rather than aggregated counters.
Use a custom HTTP handler when you need fine-grained control over the metrics endpoint path or authentication.
expvar is a snapshot. Pull the JSON, parse the state, move on.