skip to content

What does a blank import of Go's expvar package register, and what does /debug/vars serve?

level: juniorimportance: should knowfreq 40%

answer

  1. the import itself is the registration
  2. init runs, you do not call anything
  3. a well-known path on the default mux
  4. two variables arrive for free
  5. cmdline is your whole argument list

basics

~10 s

Importing expvar runs its init, which registers a handler for /debug/vars on http.DefaultServeMux and publishes two variables of its own, cmdline and memstats. The endpoint returns every published variable as one JSON object.

solid answer

~40 s

`expvar` is a side-effect package. Its `init` registers a handler for `/debug/vars` on `http.DefaultServeMux`, and publishes `cmdline` (the process's `os.Args`) and `memstats` (a full runtime memory reading, computed at request time). Anything you register later with `expvar.NewInt`, `expvar.NewMap` or `expvar.Publish` joins the same global registry and appears in the same document. A GET to `/debug/vars` renders one JSON object keyed by variable name. The consequence worth stating out loud is that the import alone is enough: `_ "expvar"` in your code or in any dependency is what puts the route on the default mux, and whether it is reachable depends entirely on whether you serve `http.DefaultServeMux` — which is what passing `nil` as the handler to `http.ListenAndServe` does.

code

go · 12 lines
go
package main

import (
	_ "expvar" // init registers /debug/vars on http.DefaultServeMux
	"log"
	"net/http"
)

func main() {
	// A nil handler means http.DefaultServeMux, so /debug/vars is now served here.
	log.Fatal(http.ListenAndServe(":8080", nil))
}

go deeper

for a junior

Be ready to say that the blank import alone creates the endpoint, to name the path /debug/vars, and to name the two free variables cmdline and memstats.

for a middle

Explain the chain: package init registers on http.DefaultServeMux, and passing nil as the handler to ListenAndServe is what makes that mux — and the route — reachable.

for a senior

Show that you would check what a new dependency's init registers, and that you know exposure is decided by which mux each listener serves, not by expvar itself.

for a principal

Own the rule that services build their own mux and mount expvar.Handler deliberately, so no import can add a public surface that nobody reviewed.

## What expvar is `expvar` is a small standard-library package for publishing named values out of a running Go process. A published value is any type implementing the `expvar.Var` interface, which has exactly one method: ```go type Var interface { String() string // must return a valid JSON value } ``` The package keeps a single process-wide registry of name to `Var`, and it knows how to render that registry as one JSON object. ## The init side effect Unlike most packages, `expvar` does two things purely as a side effect of being linked into the binary. Its `init` function: 1. Registers an HTTP handler for the path `/debug/vars` on `http.DefaultServeMux`. 2. Publishes two variables of its own: `cmdline` and `memstats`. That is why you will see the import written as `_ "expvar"` — the blank identifier means "link this package and run its init, I am not referring to any of its identifiers." There is no `expvar.Register()` you have to remember to call, and there is no build tag or debug-build gate involved. If the package is in the import graph, the registration happened. `cmdline` is the process's `os.Args`, rendered as a JSON array of strings. `memstats` is a dump of the runtime's memory statistics. Both are registered as computed variables, so `memstats` is not a cached snapshot: each request asks the runtime for a fresh reading, which is not free. ## What the endpoint returns A GET to `/debug/vars` produces a single JSON object whose keys are the published names: ``` { "cmdline": ["/usr/local/bin/worker","-queue=orders"], "memstats": { ... }, "worker.jobs_done": 18422 } ``` The handler is read-only. There is no method, header or query parameter that resets a counter, and `expvar` offers no way to remove a variable once it is published. Everything that changes a value is code running inside the process. ## The part that surprises people The route lands on `http.DefaultServeMux`, the package-level mux that `net/http` creates for you. Whether `/debug/vars` is actually reachable is therefore not a property of `expvar` at all — it is decided by whether anything you listen on serves that mux. The idiom that connects them is the `nil` handler: ```go http.ListenAndServe(":8080", nil) // nil means http.DefaultServeMux ``` With that one line and the import anywhere in the binary, port 8080 serves `/debug/vars` to anyone who can reach it, including the process's full command line. Nobody wrote a route for it, so it does not appear in the file where your routes live, and it will not show up in a reader's mental model of the service's surface. The same is true when the import is not yours. A library you depend on may import `expvar` to publish its own counters, and the registration comes along with it. Reading what a new dependency's `init` does is the habit this teaches. ## Taking the decision back The fix is not to avoid `expvar`; it is to stop serving `http.DefaultServeMux` on a listener that takes untrusted traffic: ```go mux := http.NewServeMux() mux.HandleFunc("/healthz", handleHealthz) srv := &http.Server{Addr: ":8080", Handler: mux} ``` A mux you built contains only the routes you wrote into it, so no import can add to it. Then, where you do want the variables, mount them yourself: ```go admin := http.NewServeMux() admin.Handle("/debug/vars", expvar.Handler()) ``` `expvar.Handler()` returns an `http.Handler` that serves exactly the same document. Mounting it explicitly turns an invisible side effect into one reviewable line, and it lets you choose the path and the surface rather than inheriting both. ## What to remember The import is the registration. The path is `/debug/vars`. Two variables, `cmdline` and `memstats`, come for free and are more revealing than people expect. Reachability is a property of which mux your listener serves, not of the package.

  • Which variables does expvar publish before you register anything of your own?
    Two. `cmdline` is the process's `os.Args` as a JSON array, and `memstats` is a full runtime memory reading. Both are registered by expvar's own `init` as computed variables, so `memstats` is recomputed on every request rather than cached — each scrape asks the runtime for a fresh reading.
  • If you never want /debug/vars on your public listener, what is the fix?
    Stop serving `http.DefaultServeMux`. Build `mux := http.NewServeMux()`, register only your own routes, and give that mux to your `http.Server` instead of passing `nil`. Then mount `expvar.Handler()` yourself wherever you actually want the variables. The import's side effect still fires; nothing you serve routes to it.
  • Can a caller change anything through /debug/vars?
    No. The handler renders the registry and returns it; there is no parameter or method that resets a counter, and expvar provides no way to unpublish a variable once registered. The endpoint is read-only — which is not the same as harmless, since what it reads out includes the process's command line.

saying these in an interview costs you the question

  • Thinks you must call a register function; the import is enough
  • Believes /debug/vars appears only in debug builds
  • Assumes the endpoint is private or authenticated by default
  • Thinks memstats is a cached value refreshed in the background
  • Confuses the blank import with a compiler or linker flag