The expvar Package — Junior Level¶
Table of Contents¶
- Introduction
- Prerequisites
- Glossary
- Core Concepts
- Real-World Analogies
- Mental Models
- Pros & Cons
- Use Cases
- Code Examples
- Coding Patterns
- Clean Code
- Product Use / Feature
- Error Handling
- Security Considerations
- Performance Tips
- Best Practices
- Edge Cases & Pitfalls
- Common Mistakes
- Common Misconceptions
- Tricky Points
- Test
- Tricky Questions
- Cheat Sheet
- Self-Assessment Checklist
- Summary
- What You Can Build
- Further Reading
- Related Topics
- Diagrams & Visual Aids
Introduction¶
Focus: "What is
expvar?" and "How do I expose a number from my running program over HTTP?"
You wrote a Go service. It is running. You want to know, right now, without attaching a debugger or adding a logging line and redeploying: how many requests has it served? How much memory is it using? How many items are in that cache?
expvar is the standard library's answer to that question, and it is delightfully small. Import the package, and it does two things automatically:
- It registers an HTTP handler at the path
/debug/varson the default HTTP mux. - It publishes two variables for free:
cmdline(the command-line arguments your program was started with) andmemstats(a large struct of runtime memory statistics).
When you hit /debug/vars, you get a single JSON document with every published variable in it. That's the whole idea: expose public application variables as JSON over HTTP.
package main
import (
_ "expvar" // import for side effect: registers /debug/vars
"net/http"
)
func main() {
http.ListenAndServe(":8080", nil)
}
Run that, then in another terminal:
You get a JSON object with cmdline and memstats already populated. You did not write a handler. You did not define a struct. The blank import (_ "expvar") was enough.
After reading this file you will: - Understand what expvar is and the /debug/vars endpoint - Know the Var interface and the built-in types: Int, Float, String, Map, Func - Publish your own counters and gauges - Know why all the types are safe to use from many goroutines at once - Understand what expvar is not (it is not Prometheus) - Know never to expose /debug/vars to the public internet
You do not need to understand metrics pipelines, histograms, labels, or OpenTelemetry yet. This file is about the moment you say "I just want to see a number from my running program."
Prerequisites¶
- Required: A working Go installation, version 1.21 or newer.
expvarhas been in the standard library since Go 1.0, so anything modern works. Check withgo version. - Required: Basic knowledge of
net/http— specificallyhttp.ListenAndServeand what a "mux" (request router) is. See the HTTP section of the roadmap ifnilas the second argument toListenAndServeis unfamiliar. - Required: Comfort with goroutines at a conceptual level: you should know that a web server handles many requests concurrently, which is why thread-safety matters.
- Helpful: Familiarity with JSON —
expvaroutputs JSON and you will read it. - Helpful:
curlor any HTTP client to hit the endpoint.
If go version prints go1.21 or higher and you can run a basic net/http server, you are ready.
Glossary¶
| Term | Definition |
|---|---|
expvar | The standard library package (expvar) for publishing public variables as JSON over HTTP. |
/debug/vars | The conventional HTTP path where expvar's handler serves the JSON document. |
Var interface | The single-method interface (String() string) that every published variable implements. String() must return valid JSON. |
| Publish | To register a named Var in the global registry so it appears in /debug/vars. Done via expvar.Publish or the New* constructors. |
| Side-effect import | A blank import (_ "expvar") used purely to run the package's init, which registers the handler. No symbols are referenced. |
Int | A published 64-bit integer variable, safe for concurrent use, with Add and Set. |
Float | A published 64-bit float variable, safe for concurrent use, with Add and Set. |
String | A published string variable, safe for concurrent use, with Set. |
Map | A published key→Var map (think: per-label counters), with Add, AddFloat, Set, and Do. |
Func | A Var backed by a function: it is computed every time /debug/vars is read. Good for gauges. |
| Counter | A value that only goes up (e.g. total requests). Use Int.Add(1). |
| Gauge | A value that goes up and down (e.g. current connections, queue depth). Often a Func. |
DefaultServeMux | The package-global HTTP router net/http uses when you pass nil to ListenAndServe. expvar registers its handler here. |
Core Concepts¶
What expvar actually does on import¶
The entire activation mechanism is a side-effect import. The package's init function runs this:
That single line registers the JSON handler on http.DefaultServeMux. It also publishes the two default variables. From that point on, any HTTP server using the default mux will answer /debug/vars.
This is why you usually see _ "expvar" — the underscore means "import this package only to run its init; I am not going to reference any of its names." If you do call expvar.NewInt(...) etc., then it's a normal import (no underscore) and the init still runs.
The two default variables¶
You get these for free, with no code:
cmdline— a JSON array of the command-line arguments (os.Args). Useful to confirm which binary and flags a running process was started with.memstats— the fullruntime.MemStatsstruct as JSON. Heap size, GC count, allocation totals, and dozens more fields. This is a live snapshot, recomputed on each read.
The Var interface¶
Everything expvar publishes satisfies one tiny interface:
That's it — a single method that returns a string. The crucial rule: the returned string must be valid JSON. When /debug/vars is served, expvar builds a JSON object whose values are the raw output of each variable's String(). If your String() returns something that is not valid JSON, the whole document becomes malformed.
The built-in types handle this for you. An Int returns 42 (a valid JSON number). A String returns "hello" with quotes (a valid JSON string). You only need to worry about the JSON rule when you write a custom Var.
The built-in types¶
expvar ships four data types plus a function adapter:
Int— a thread-safeint64. Methods:Add(delta int64),Set(v int64),Value() int64.Float— a thread-safefloat64. Methods:Add(delta float64),Set(v float64),Value() float64.String— a thread-safe string. Methods:Set(v string),Value() string.Map— a thread-safe map fromstringkeys toVarvalues. Methods:Add,AddFloat,Set,Get,Delete,Do,Init.Func— wraps afunc() any; its value is recomputed on every read. Use for gauges and derived values.
Publishing: two ways¶
Way 1 — the New* constructors create the variable and register it under a name:
Now hits is a *expvar.Int, and it will appear in /debug/vars under the key "hits".
Way 2 — Publish(name, Var) registers an existing Var:
Both put the variable into the same global registry. The New* constructors are just convenience wrappers around Publish.
The global registry and the duplicate-name rule¶
expvar keeps a single, package-global registry of name → Var. There is exactly one per process. This has one sharp edge you must know early:
Publishing the same name twice calls log.Fatal and kills your program.
This is intentional — duplicate names would silently shadow each other — but it means you must pick unique names, and you must be careful in tests (more on that in the pitfalls section).
Reading the output¶
Hit /debug/vars and you get something like:
{
"cmdline": ["/tmp/go-build/exe/myapp"],
"hits": 17,
"memstats": { "Alloc": 123456, "TotalAlloc": 789012, ... }
}
Each top-level key is a published variable name. Each value is the raw JSON from that variable's String(). It is a pull model: nothing is pushed anywhere; you read the current state when you ask.
Real-World Analogies¶
1. The dashboard gauges in a car. Your engine is running. You do not stop the car and open the hood to learn the speed or fuel level — you glance at the dashboard, which exposes a few public readings. expvar is the dashboard: a small, always-available panel of the numbers the program chose to make visible.
2. A status board on a wall. A factory floor has a board showing "units produced today," "machines running," "defects." Anyone walking by reads it. Nobody pushes the numbers to anyone; the board just reflects the current state when you look. /debug/vars is that board, served as JSON.
3. A vending machine's coin counter. The machine tallies coins internally; a small window shows the running total. expvar.Int with Add(1) on each event is exactly this counter — increment-only, always readable.
4. A glass-walled server room. You can see the blinking lights and read the rack labels without going inside. expvar lets you see some of the program's internals from outside, through a small read-only window — without stopping it or attaching a debugger.
Mental Models¶
Model 1 — expvar is a pull-based JSON snapshot¶
Nothing is pushed. A reader hits /debug/vars and gets the current values at that instant. There is no time series, no history, no retention. If you want history, something else (a scraper) must poll the endpoint and store the results.
Model 2 — Everything is a Var, and a Var is "a thing that prints valid JSON"¶
The whole system reduces to one interface with one method. An Int prints 42; a String prints "x"; your custom type prints whatever JSON you make it print. The handler just concatenates those into one object.
Model 3 — Counters go up with Add; gauges are computed with Func¶
A counter (Int.Add(1)) accumulates an event count. A gauge (a Func returning the current value of something) reflects a live measurement. Choosing between them is the most common day-one decision.
Model 4 — One global registry, names are forever, duplicates are fatal¶
There is one registry per process. A name, once published, cannot be un-published or re-published. Re-publishing a name crashes the process. Treat names like permanent, unique identifiers.
Model 5 — expvar is the minimum observability, not the maximum¶
It is the smallest possible thing that gets a number out of a running process over HTTP. It has no labels, no histograms, no aggregation, no typing beyond "JSON." It is perfect for a quick look and a poor substitute for a real metrics system.
Pros & Cons¶
Pros¶
- Zero dependencies. It is in the standard library. No
go get, no version to manage. - Almost zero setup. A blank import and an HTTP server, and you have an endpoint.
- Concurrency-safe out of the box. Every built-in type can be updated from any goroutine without you adding a mutex.
- Free runtime insight.
memstatsandcmdlinecome for free and are genuinely useful for debugging. - JSON output. Easy to read by eye, easy to parse with
jq, easy to scrape. - Tiny footprint. No background goroutines, no buffers, no overhead until you read the endpoint.
Cons¶
- No labels or dimensions. You cannot attach
{method="GET", status="200"}to a counter the way Prometheus does. You fake it withMapkeys. - No histograms or quantiles. You cannot natively express "p99 latency."
- No types beyond JSON. A consumer cannot tell a counter from a gauge from a string; it is all untyped JSON.
- Global registry. Names are process-global, which makes testing and library use awkward.
DefaultServeMuxcoupling. The default handler registers on the global mux — a problem if you avoid the default mux for good reasons (and you should).- Security exposure.
memstatsandcmdlineleak operational details. Never expose/debug/varspublicly.
When the trade-off is acceptable¶
expvar is the right tool for quick introspection: a counter you can glance at, a debugging endpoint behind an internal network, a small service where pulling in Prometheus would be overkill. It is the wrong tool for a production metrics pipeline with dashboards and alerting.
Use Cases¶
You should reach for expvar when:
- You want a quick request counter visible without redeploying.
- You are debugging memory and want live
memstatsfrom a running process. - You have an internal-only service behind a trusted network where a JSON debug endpoint is convenient.
- You want per-something counts (per-endpoint, per-status-code) and a
Mapis enough. - You are writing a small tool and a full metrics stack is disproportionate.
- You want a health-ish snapshot that a simple script can poll.
You should not reach for expvar when:
- You need histograms, quantiles, or rate calculations — use Prometheus client or OpenTelemetry.
- You need labels/dimensions on metrics —
Mapkeys are a poor substitute at scale. - The endpoint would be reachable from the public internet without a gate.
- You need a metrics format your existing monitoring already speaks (Prometheus exposition, OTLP).
Code Examples¶
Example 1 — The smallest possible expvar program¶
package main
import (
_ "expvar"
"net/http"
)
func main() {
// /debug/vars is already registered by expvar's init.
http.ListenAndServe(":8080", nil)
}
Example 2 — A request counter¶
package main
import (
"expvar"
"net/http"
)
// NewInt publishes "requests" and returns the *expvar.Int.
var requests = expvar.NewInt("requests")
func handler(w http.ResponseWriter, r *http.Request) {
requests.Add(1) // safe to call from many goroutines
w.Write([]byte("ok"))
}
func main() {
http.HandleFunc("/", handler)
http.ListenAndServe(":8080", nil)
}
After a few requests:
requests.Add(1) is atomic — no mutex needed even though many requests run concurrently.
Example 3 — Per-status-code counts with a Map¶
package main
import (
"expvar"
"net/http"
"strconv"
)
var statusCounts = expvar.NewMap("http_status")
func handler(w http.ResponseWriter, r *http.Request) {
status := http.StatusOK
if r.URL.Path == "/fail" {
status = http.StatusInternalServerError
}
w.WriteHeader(status)
// Map.Add increments the Int under this key (creating it if absent).
statusCounts.Add(strconv.Itoa(status), 1)
}
func main() {
http.HandleFunc("/", handler)
http.HandleFunc("/fail", handler)
http.ListenAndServe(":8080", nil)
}
Map.Add(key, n) finds (or creates) the Int under key and adds n. This is how you approximate "labels" with expvar.
Example 4 — A computed gauge with Func¶
Counters use Add. A gauge (current value of something) is best done with Func, which recomputes on every read:
package main
import (
"expvar"
"net/http"
"runtime"
)
func main() {
// Recomputed every time /debug/vars is read.
expvar.Publish("goroutines", expvar.Func(func() any {
return runtime.NumGoroutine()
}))
http.ListenAndServe(":8080", nil)
}
expvar.Func takes a func() any. Whatever it returns is JSON-encoded automatically by expvar. Because it is recomputed on each read, it always shows the current value — perfect for a gauge.
Example 5 — A String variable¶
var version = expvar.NewString("version")
func main() {
version.Set("v1.4.2")
http.ListenAndServe(":8080", nil)
}
Note the output is "v1.4.2" with quotes — String.String() returns a JSON-encoded (quoted, escaped) string. You do not add quotes yourself.
Example 6 — Serving expvar on a custom mux¶
The default registration uses http.DefaultServeMux. Many teams avoid the default mux. You can mount expvar explicitly using its exported Handler():
package main
import (
"expvar"
"net/http"
)
func main() {
mux := http.NewServeMux() // our own mux, not the default
mux.Handle("/debug/vars", expvar.Handler())
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello"))
})
http.ListenAndServe(":8080", mux)
}
expvar.Handler() returns the same handler the package registers by default. By mounting it yourself, you control the mux and the path. (Even with a custom mux, expvar's init still registers on the default mux — that registration is just unused if you never serve the default mux.)
Example 7 — Iterating all published vars with Do¶
expvar.Do walks the entire registry, calling your function with each name/Var pair. Useful for logging a snapshot on shutdown, or for writing a custom exporter.
Coding Patterns¶
Pattern: declare published vars as package-level variables¶
var (
requests = expvar.NewInt("requests")
errors = expvar.NewInt("errors")
cacheHits = expvar.NewInt("cache_hits")
)
Because publication happens at package-init time and names are global, package-level var declarations are the natural home. They are created once and used everywhere.
Pattern: a Map for "labeled" counters¶
var byEndpoint = expvar.NewMap("requests_by_endpoint")
func handle(name string, w http.ResponseWriter, r *http.Request) {
byEndpoint.Add(name, 1)
}
When you would reach for a label in Prometheus, reach for a Map key in expvar.
Pattern: Func for anything derived¶
Never try to keep a gauge in sync by hand (incrementing on connect, decrementing on disconnect is bug-prone). Compute it on read:
expvar.Publish("queue_depth", expvar.Func(func() any {
return len(workQueue) // read the live value
}))
Pattern: namespace your names¶
Names are flat and global. Prefix them to avoid collisions and to group related vars:
A _ or . separator (db.queries) is common; pick one and be consistent.
Clean Code¶
- Use the
New*constructors when you want create-and-publish in one line; usePublishwhen you already hold theVar. - Do not pass
expvarvariables around as values.expvar.Inthas internal state; always use pointers (*expvar.Int), which is whatNewIntreturns. - Prefer
Funcover manual gauge bookkeeping. A computed-on-read gauge cannot drift; a hand-maintained one will. - Keep names stable. Renaming a published var breaks every scraper and dashboard that reads it. Treat names as part of your API.
- Don't publish secrets.
cmdlinealready risks leaking flags; neverexpvar.NewString("api_key"). - Mount
expvaron a mux you control rather than relying on the default mux being served, so the endpoint's location and access are explicit.
Product Use / Feature¶
In a real service, expvar typically supports:
- A debug endpoint on an internal admin port, alongside
net/http/pprof, that on-call engineers hit to read live counters. - Lightweight readiness signals — "items processed," "last successful sync," "current backlog" — visible without a metrics stack.
- Memory triage —
memstatsgives heap and GC numbers during an incident, without re-deploying with extra logging. - A cheap scrape target — a small polling script reads
/debug/varsevery minute and writes the numbers to a log or simple time-series store.
For a serious product with dashboards, SLOs, and alerting, expvar is usually a stepping stone: you start with it, and graduate to Prometheus/OpenTelemetry when you need labels, histograms, and aggregation.
Error Handling¶
expvar is almost error-free by design — there are very few places it can fail — but the failures it does have are abrupt.
Duplicate publish → log.Fatal¶
The message is Reuse of exported var name: hits. There is no error return; the program exits. This bites most often in tests that run init code twice, or when two packages publish the same name. The fix: unique names; in tests, guard publication or use a fresh process.
Malformed JSON from a custom Var¶
If you write your own Var and its String() returns invalid JSON, the entire /debug/vars document becomes invalid — one bad variable poisons the whole response. The built-in types never do this; only custom Vars are at risk. The fix: always json.Marshal and return the result, or build a string you are certain is valid JSON.
type stat struct{ data map[string]int }
func (s stat) String() string {
b, err := json.Marshal(s.data)
if err != nil {
return "null" // valid JSON fallback, never an empty or raw string
}
return string(b)
}
A panicking Func¶
If the function inside expvar.Func panics, the panic propagates through the HTTP handler. Keep Func bodies simple and non-panicking; do a cheap read, return a value.
Security Considerations¶
This is the most important section for a junior to internalize: /debug/vars is a leak if exposed publicly.
cmdlineleaks how the process was started — binary path, and any flags passed on the command line. If you pass secrets as flags (you should not), they appear here.memstatsleaks operational detail — memory usage, GC behaviour, allocation patterns. Useful to an attacker profiling your service.- Your own vars may leak business data — a careless
expvar.NewString("current_user_email")is now world-readable.
Rules:
- Never serve
/debug/varson a public-facing port. Put it on an internal admin port, or behind authentication, or bind it to localhost only. - Do not rely on "nobody knows the path."
/debug/varsis a well-known, documented path. Security by obscurity fails. - Avoid the default mux for public servers. Because
expvar(andpprof) auto-register onhttp.DefaultServeMux, a public server that happens to use the default mux silently exposes these endpoints. Use your own mux for public traffic and mount debug endpoints only on an internal one. - Gate it. If it must be reachable, wrap
expvar.Handler()in an auth-checking middleware.
A common safe layout: public traffic on :8080 via a custom mux; debug endpoints (/debug/vars, /debug/pprof) on :6060 bound to 127.0.0.1, reachable only by an SSH tunnel or a sidecar.
Performance Tips¶
Int.AddandFloat.Addare cheap. They are backed by atomic operations, not mutexes — calling them on a hot path is fine.Mapoperations take a lock briefly. AMap.Addis more expensive than anInt.Addbecause the map is protected by internal locking. Fine for moderate rates; for extremely hot per-key counts, consider pre-creating theInts.Funcruns on every read. Keep the function cheap. Reading/debug/varscalls everyFunc; an expensiveFuncmakes the endpoint slow and can cause work proportional to scrape frequency.memstatsis recomputed each read and callsruntime.ReadMemStats, which briefly stops the world in older Go versions. Do not scrape/debug/varsat extremely high frequency if it includesmemstats.- There is no cost when nobody reads.
expvardoes no background work; the only cost is updating your counters and serving the occasional request.
Best Practices¶
- Use a blank import only if you want the side effect (
_ "expvar"); use a normal import if you call its functions. - Declare published vars at package level with the
New*constructors. - Use
Funcfor gauges,Addfor counters. Do not hand-maintain gauge state. - Use
Mapfor per-key counts instead of dozens of separate namedInts. - Pick stable, namespaced names. They are part of your observability contract.
- Mount
expvar.Handler()on an internal mux, not the public one. - Never publish secrets and never expose the endpoint publicly.
- Treat
expvaras a starting point; graduate to Prometheus/OTel when you outgrow it.
Edge Cases & Pitfalls¶
Pitfall 1 — Forgetting the blank import¶
If you never import expvar (directly or via a constructor call), nothing is registered and /debug/vars 404s. The blank import _ "expvar" exists precisely to run the registering init.
Pitfall 2 — Publishing the same name twice crashes the process¶
log.Fatal on duplicate names is not a warning — it exits. Two packages publishing "requests", or a test that publishes the same name on each run, will kill the program.
Pitfall 3 — A custom Var that returns non-JSON¶
A String() that returns hello (not "hello") makes the entire /debug/vars document invalid. Always JSON-encode.
Pitfall 4 — Expecting String.Set("x") to output x¶
String.Set("x") is stored and rendered as "x" — the output is a JSON string, quoted and escaped. This surprises people who expect raw output. If you genuinely need raw JSON, do not use String; write a custom Var.
Pitfall 5 — Relying on the default mux being served¶
expvar's registration is on http.DefaultServeMux. If your server uses a custom mux (and serves it), /debug/vars will not be reachable unless you also mount expvar.Handler() on that mux. The auto-registration is silently useless in that case.
Pitfall 6 — Treating Map key order as stable¶
Map.String() sorts keys (Go's implementation sorts them for deterministic output), but do not write code that depends on a particular iteration order beyond that. Use Do for explicit iteration.
Pitfall 7 — Hand-maintaining a gauge¶
connections.Add(1) on connect and connections.Add(-1) on disconnect looks fine but drifts the moment a disconnect path is missed (panic, early return). Prefer a Func that reads the live count.
Pitfall 8 — Scraping memstats too often¶
Every read of /debug/vars recomputes memstats. High-frequency scraping of the full endpoint adds runtime overhead. Scrape modestly, or expose only the specific numbers you need via Func.
Common Mistakes¶
- No import → no endpoint. Forgetting
_ "expvar"(or anyexpvaruse) means/debug/varsdoes not exist. - Duplicate names crashing the process via
log.Fatal. - Custom
Varreturning invalid JSON, poisoning the whole document. - Exposing
/debug/varspublicly, leakingcmdlineandmemstats. - Using
Stringand being surprised by the quotes in the output. - Hand-maintaining gauges instead of using
Func. - Creating many named
Ints where a singleMapwould be cleaner. - Passing
expvar.Intby value instead of using the pointer fromNewInt.
Common Misconceptions¶
"
expvaris a metrics system like Prometheus."
No. It is a way to expose JSON variables over HTTP. It has no labels, no histograms, no aggregation, no typing. It is the minimum, not a metrics platform.
"I have to write the
/debug/varshandler myself."
No. Importing the package registers it automatically. You only mount expvar.Handler() manually if you use a custom mux.
"
expvarpushes my metrics somewhere."
No. It is pull-only. A reader fetches /debug/vars; nothing is pushed. If you want push, you build a scraper.
"
String.Set("v1")outputsv1."
No. It outputs "v1" — a JSON string with quotes. All built-in types output valid JSON, which for strings means quoting.
"
expvartypes need a mutex around them."
No. Int, Float, String, and Map are all safe for concurrent use already. Adding your own lock is redundant.
"It's safe to leave
/debug/varsopen; it's just numbers."
No. cmdline and memstats are operationally sensitive, and your own vars might leak data. Gate it.
Tricky Points¶
- The
initregisters onDefaultServeMuxregardless of whether you serve it. Importingexpvaralways touches the default mux; the registration is just unused if you serve a different mux. Func's argument isfunc() any, and the return value is JSON-marshaled byexpvar. It does not need to be aVar; the package wraps it.Map.AddandMap.AddFloatcreate the key if absent; the firstAddto a new key implicitly creates anInt(orFloat).Getreturnsnilfor an unknown name, so always nil-check the result ofexpvar.Get.memstatsis aFuncinternally, which is why it is always current — it callsruntime.ReadMemStatson each read.- Names cannot be removed. There is no
Unpublish. The registry only grows during the process lifetime. NewMapreturns an empty, ready-to-use*Map; you do not need toInitit beforeAdd.
Test¶
Try this in a scratch folder.
package main
import (
"expvar"
"fmt"
"net/http"
)
var hits = expvar.NewInt("hits")
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
fmt.Fprintln(w, "ok")
})
expvar.Publish("answer", expvar.Func(func() any { return 42 }))
http.ListenAndServe(":8080", nil)
}
Expected: hits reflects the number of / requests, and answer is 42.
Now answer: 1. What happens if you add a second expvar.NewInt("hits")? (Answer: log.Fatal, the process exits.) 2. Does String.Set("v") output v or "v"? (Answer: "v", quoted.) 3. If you switch to a custom mux and pass it to ListenAndServe, does /debug/vars still work? (Answer: not unless you mount expvar.Handler() on that mux.) 4. Is hits.Add(1) safe to call from many goroutines? (Answer: yes, it is atomic.)
Tricky Questions¶
Q1. Why is the import usually written as _ "expvar"?
A. Because most programs want only the side effect — registering /debug/vars on the default mux and publishing cmdline/memstats. The blank import runs the package's init without referencing any of its names. If you also call expvar.NewInt(...), drop the underscore.
Q2. I published a var but /debug/vars 404s. Why?
A. Most likely you are serving a custom mux instead of the default mux, and you did not mount expvar.Handler() on it. The auto-registration is on http.DefaultServeMux only.
Q3. Counter or gauge — how do I choose?
A. If it only ever increases (total requests, total errors), it is a counter: use Int.Add(1). If it goes up and down (current connections, queue depth), it is a gauge: use a Func that reads the live value.
Q4. Can two goroutines call requests.Add(1) at the same time safely?
A. Yes. Int is backed by atomic operations. No mutex needed.
Q5. My custom Var broke the whole /debug/vars output. What happened?
A. Its String() returned something that is not valid JSON. The handler concatenates each var's String() into one JSON object; one invalid value makes the entire document invalid. JSON-encode your value.
Q6. Is it safe to leave /debug/vars reachable from the internet?
A. No. cmdline and memstats leak operational detail, and your own vars may leak data. Bind it to localhost, put it on an internal port, or require authentication.
Q7. How do I see all the variables in Go code instead of over HTTP?
A. Use expvar.Do(func(kv expvar.KeyValue){ ... }), which iterates the whole registry. Or expvar.Get("name") for a single one (it returns nil if not found).
Q8. Why does expvar use log.Fatal for duplicate names instead of returning an error?
A. Because publication happens at init time with no error channel, and a silent shadow would be worse than a crash. It forces you to fix the collision immediately.
Q9. Does Map give me Prometheus-style labels?
A. Loosely. A Map key is a single string dimension. It approximates one label (e.g. status code), but it has no multi-dimensional labels, no histograms, and no aggregation. For real labels, use a metrics library.
Q10. What's the difference between expvar.NewInt("x") and expvar.Publish("x", new(expvar.Int))?
A. None functionally — NewInt is a convenience wrapper that creates an *Int, calls Publish, and returns the pointer. Both register an Int under the name x.
Cheat Sheet¶
// Publish + create in one line
var hits = expvar.NewInt("hits")
var ratio = expvar.NewFloat("ratio")
var ver = expvar.NewString("version")
var byKey = expvar.NewMap("by_key")
// Counter (increment-only)
hits.Add(1)
// Gauge (computed on each read)
expvar.Publish("goroutines", expvar.Func(func() any {
return runtime.NumGoroutine()
}))
// Per-key ("labeled") counts
byKey.Add("GET", 1)
byKey.AddFloat("latency_sum", 0.042)
// Set values
ver.Set("v1.2.3") // renders as "v1.2.3"
ratio.Set(0.95)
// Mount on a custom mux
mux.Handle("/debug/vars", expvar.Handler())
// Read in Go
v := expvar.Get("hits") // nil if absent
expvar.Do(func(kv expvar.KeyValue) {
fmt.Println(kv.Key, kv.Value.String())
})
Defaults you get for free:
cmdline -> ["/path/to/binary", "-flag", ...]
memstats -> { "Alloc":..., "NumGC":..., ... }
| Symptom | Likely Cause | Fix |
|---|---|---|
/debug/vars 404s | No expvar import, or custom mux | Add _ "expvar" or mount expvar.Handler() |
| Process exits on startup | Duplicate published name | Pick unique names |
| Whole JSON is invalid | Custom Var returns non-JSON | JSON-encode in String() |
version shows "v1" not v1 | String always quotes | Expected; use custom Var for raw JSON |
| Gauge value drifts | Hand-maintained counter | Use Func instead |
| Endpoint leaks data | Served publicly | Bind to localhost / gate it |
Self-Assessment Checklist¶
You can move on to middle.md when you can:
- Explain what
expvardoes on import and what/debug/varsreturns - Name the two default variables and what they contain
- State the
Varinterface and its one rule (valid JSON fromString()) - Use
Int,Float,String,Map, andFunc - Choose between a counter (
Add) and a gauge (Func) - Use a
Mapfor per-key counts - Publish a var two ways (
NewIntvsPublish) - Mount
expvaron a custom mux withexpvar.Handler() - Explain why all the built-in types are concurrency-safe
- Explain why duplicate names crash the process
- Explain why
/debug/varsmust not be exposed publicly
Summary¶
expvar is the standard library's smallest observability tool: import it, and it serves your program's public variables as JSON at /debug/vars, including two freebies (cmdline, memstats). Everything it publishes satisfies the one-method Var interface whose String() returns valid JSON. The built-in types — Int, Float, String, Map, and the Func adapter — cover counters, gauges, and per-key counts, and every one of them is safe to use from many goroutines without a mutex.
You publish with the New* constructors or with Publish(name, Var); names live in a single global registry, are permanent, and crash the process (log.Fatal) if reused. Use Add for counters, Func for gauges, and Map keys where you would otherwise want labels. Mount the endpoint on a mux you control, keep the function bodies cheap, and — above all — never expose /debug/vars to the public internet, because cmdline and memstats leak operational detail.
expvar is the minimum, not the maximum: perfect for quick introspection and debugging, and a fine stepping stone toward Prometheus or OpenTelemetry when you need labels, histograms, and aggregation.
What You Can Build¶
After learning this:
- A self-instrumented HTTP service with live request/error counters visible at
/debug/vars. - A memory-triage endpoint that exposes
memstatsfor incident debugging without a redeploy. - A per-endpoint or per-status-code dashboard source using a single
Map. - A tiny scraper that polls
/debug/varsand logs the numbers over time. - An internal admin port that mounts
expvarandpproftogether, gated from public traffic.
You cannot yet: - Express histograms or quantiles (next: Prometheus client / OpenTelemetry) - Attach multi-dimensional labels to metrics - Build a full alerting pipeline (later, in the metrics topics) - Write a custom exporter that translates expvar into another format (middle/professional)
Further Reading¶
expvarpackage documentation — authoritative, terse, the source of truth.runtime.MemStats— what every field inmemstatsmeans.net/http/pprof— the sibling debug endpoint, same default-mux pattern.- The Go Blog: profiling Go programs — context for runtime introspection.
net/http.ServeMux— why the default mux matters forexpvar.
Related Topics¶
- 17.1
net/http/pprof— the profiling sibling; same auto-registration pattern - 17.3
runtimemetrics —runtime/metricsfor richer runtime numbers - 17.4 Prometheus client — labels, histograms, the next step up from
expvar - 17.5 OpenTelemetry metrics — the modern, vendor-neutral metrics stack
- HTTP servers & muxes — why a custom mux beats the default for public traffic
Diagrams & Visual Aids¶
What import "expvar" wires up:
import _ "expvar"
│
│ package init runs:
▼
http.HandleFunc("/debug/vars", expvarHandler) ──> DefaultServeMux
│
├─ publishes "cmdline" (os.Args)
└─ publishes "memstats" (runtime.MemStats, recomputed on read)
The pull model:
your program HTTP client / scraper
┌───────────┐ │
│ requests │ GET /debug/vars
│ Int = 17 │ <─────────────┘
│ status │
│ Map{...} │ ──────────────> {"requests":17,"status":{...},...}
└───────────┘ (current snapshot, no history)
The Var interface, everything reduces to it:
type Var interface { String() string } // must return VALID JSON
Int.String() -> 42
Float.String() -> 0.95
String.String() -> "hello" (quoted!)
Map.String() -> {"GET":3,"POST":1}
Func.String() -> <json of fn() result>
│
▼
handler concatenates them:
{ "name1": <json>, "name2": <json>, ... }
Counter vs Gauge:
Counter (only goes up) Gauge (up and down)
┌──────────────────┐ ┌──────────────────────┐
│ requests.Add(1) │ │ expvar.Func(func() │
│ errors.Add(1) │ │ any { return │
│ │ │ len(queue) }) │
│ value accumulates│ │ recomputed each read │
└──────────────────┘ └──────────────────────┘
Safe vs unsafe exposure:
PUBLIC :8080 INTERNAL :6060 (127.0.0.1)
┌──────────────┐ ┌──────────────────────┐
│ custom mux │ │ /debug/vars │
│ / │ │ /debug/pprof │
│ /api/... │ │ (gated, localhost) │
│ (no /debug) │ └──────────────────────┘
└──────────────┘
clients on-call engineers only
In this topic
- junior
- middle
- senior
- professional