Standard Library in Practice
Irbisa · cheatsheetSeptember 19, 2026

Standard Library in Practice

io and bufio, encoding/json, time, strconv, regexp, embed

Middle Developer16 itemscompressed for a skim
  1. 01

    Your handler builds a response struct, json.Marshal returns no error, and the client receives {} — the debugger shows every field populated. What is wrong with the struct?

    Easy

    encoding/json works through reflection and can only see exported fields, so a struct whose fields start with a lowercase letter marshals to {} and decodes into nothing.

    // ❌ nothing is exported: Marshal returns "{}" and a nil error
    type order struct {
    	id     int64
    	total  int64 `json:"total"` // a tag on an unexported field does nothing
    	status string
    }
    …
  2. 02

    A log line comes out as the literal text YYYY-MM-DD hh:mm:ss, and time.Parse("DD/MM/YYYY", s) errors on every input. What does Go want in that argument instead?

    Easy

    A Go layout is not a pattern of placeholders but an example of the reference time Mon Jan 2 15:04:05 MST 2006 — anything that is not part of that timestamp is copied through literally, which is why YYYY-MM-DD formats as itself.

    const layout = "2006-01-02 15:04:05" // an example, not a pattern
    
    func main() {
    	t := time.Date(2024, 3, 5, 9, 7, 0, 0, time.UTC)
    
    	// ❌ Format never errors: unknown letters are copied through as themselves
    …
  3. 03

    An export job writes 200k rows through a bufio.Writer and the file on disk is a few kilobytes short, always cut mid-row. No call returned an error. What is missing?

    Easy

    bufio.Writer keeps the tail of your output in a 4 KB in-memory buffer, so without a Flush — and a check of the error it returns — whatever is still buffered when the function ends never reaches the file.

    type Row struct {
    	ID   int64
    	Name string
    }
    
    // ❌ the last partial buffer is lost; Close does not flush the writer
    …
  4. 04

    os.ReadFile("templates/welcome.html") works under go run . and fails with no such file or directory once the binary is copied to the server. Why, and what do you ship instead?

    Easy

    A relative path is resolved against the process's working directory, not the binary and not the package that named it — locally go run happens to start you in the package directory, and systemd or a container WORKDIR does not.

    //go:embed all:templates
    var templates embed.FS // compiled into the binary: no cwd, nothing to copy
    
    func render(w io.Writer, name string, data any) error {
    	// ❌ resolved against whatever directory the process happens to be started in
    	// b, err := os.ReadFile("templates/" + name)
    …
  5. 05

    You have to upload a file while gzipping it, hashing the plaintext and counting the bytes that actually go out — one pass, nothing held in memory. Why does this end up being a handful of lines of stdlib?

    Medium

    Because every streaming API in the standard library speaks io.Reader and io.Writer — one method each — so compression, hashing and counting are wrappers you stack, and io.TeeReader, io.MultiWriter and io.Copy do the plumbing.

    // One pass over the disk: gzip to the wire, sha256 of the plaintext,
    // and a count of the compressed bytes.
    func upload(dst io.Writer, name string) (sum []byte, n int64, err error) {
    	f, err := os.Open(name)
    	if err != nil {
    		return nil, 0, err
    …
  6. 06

    A 12 GB POST to an endpoint whose first line is body, _ := io.ReadAll(r.Body) takes the whole service down. What do you wrap the reader in, and what does that wrapper not tell you?

    Medium

    Wrap it in io.LimitReader(r, n) so the client can no longer choose how much memory you allocate — but a bare LimitReader reports the cut as a clean io.EOF, so truncation is invisible unless you read one byte past your ceiling and check.

    const maxBody = 1 << 20 // 1 MiB
    
    // ❌ the caller decides how much memory you allocate
    func readBad(r io.Reader) ([]byte, error) {
    	return io.ReadAll(r)
    }
    …
  7. 07

    A line-by-line importer has run for a year and now dies on one customer's file with bufio.Scanner: token too long — and only because someone recently added an Err() check. What is the limit, and what are the ways out?

    Medium

    bufio.Scanner refuses any token bigger than bufio.MaxScanTokenSize, 64 KB: Scan() simply returns false, and the reason shows up only in Err().

    func importLines(r io.Reader, handle func([]byte) error) error {
    	s := bufio.NewScanner(r)
    	// Default ceiling is bufio.MaxScanTokenSize (64 KB): one long line ends
    	// the loop exactly like a clean EOF. Raise it deliberately.
    	s.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) // 64 KB start, 4 MB max
    …
  8. 08

    The mobile client crashes on response.tags.length for exactly the users who have no tags, and a time.Time field you tagged omitempty is in every payload as 0001-01-01T00:00:00Z. Explain both.

    Medium

    A nil slice marshals to null while an allocated empty one marshals to [], and omitempty only recognises Go's empty values — false, 0, "", a nil pointer, interface or map, and a length-zero map, slice or array — so a zero struct such as time.Time is never empty.

    type Profile struct {
    	Tags    []string  `json:"tags"`              // nil -> null, []string{} -> []
    	Nick    string    `json:"nick,omitempty"`    // "" -> key absent
    	Score   int       `json:"score,omitempty"`   // 0 -> absent: "sent 0" is unsayable
    	Seen    *int      `json:"seen,omitempty"`    // nil -> absent, &0 -> "seen":0
    	Created time.Time `json:"created,omitempty"` // ❌ a struct is never "empty"
    …
  9. 09

    A webhook relay decodes each payload into map[string]any, re-encodes it and forwards it on. Orders started arriving downstream with an id one off from the source system's. What happened in between?

    Medium

    Decoding into any turns every JSON number into a float64, and a float64 carries 53 bits of mantissa — an id above 2^53 is rounded on the way in, and the re-encode faithfully writes the rounded value out.

    const raw = `{"order_id":12345678901234567,"amount":"10.50"}`
    
    func main() {
    	// ❌ any makes every number a float64: 53 bits of mantissa
    	var m map[string]any
    	json.Unmarshal([]byte(raw), &m)
    …
  10. 10

    Ops renamed a deploy-template key from max_conns to maxConnections, the service booted without a word and ran with a pool of zero. Which two encoding/json decode rules made that silent, and how do you make it loud?

    Medium

    Unknown keys are silently discarded and known ones are matched case-insensitively, so a renamed key vanishes without a trace while a mis-cased one is quietly accepted — Decoder.DisallowUnknownFields() turns the first case into an error.

    type Config struct {
    	Addr     string `json:"addr"`
    	MaxConns int    `json:"max_conns"`
    }
    
    func load(r io.Reader) (Config, error) {
    …
  11. 11

    A pprof profile of a hot request path puts fmt.Sprintf and regexp.Compile together at about a third of CPU. Neither call site looks expensive. What is each one doing per call?

    Medium

    fmt.Sprintf boxes every argument into an any and walks the format string with reflection, and a regexp.MustCompile inside a function rebuilds the whole automaton on every call — both are per-call work that belongs to strconv and to a package-level variable.

    // ✅ compiled once at init; *Regexp is safe for concurrent use
    var slugRe = regexp.MustCompile(`^[a-z0-9_]{3,16}$`)
    
    // ❌ ~4.3 µs and ~180 allocations per call: the automaton is rebuilt every time
    func validBad(s string) bool {
    	return regexp.MustCompile(`^[a-z0-9_]{3,16}$`).MatchString(s)
    …
  12. 12

    After a port to log/slog the JSON lines contain "!BADKEY":"user_id", and one line carries the key attempt twice. What do those two mean, and what should the call have looked like?

    Medium

    The variadic form reads its arguments as alternating key/value pairs — a lone or mis-ordered argument becomes !BADKEY, and nothing deduplicates keys — so the fix is typed slog.Attr values, one argument per field.

    func handle(ctx context.Context, id string, d time.Duration) {
    	log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    		Level: slog.LevelInfo,
    	}))
    
    	// ❌ odd argument: there is no key to pair it with
    …
  13. 13

    You add a MarshalJSON to Event so its timestamp serialises as a plain date, and the first request that touches it kills the process with fatal error: stack overflow. The method body is one json.Marshal call. Explain the loop — and why type alias = Event does not break it.

    Hard

    json.Marshal checks for the json.Marshaler interface before it looks at fields, so marshalling the same type re-enters your method forever — the escape is a defined type (type eventJSON Event), which copies the fields and starts with an empty method set.

    type Event struct {
    	Name string    `json:"name"`
    	At   time.Time `json:"at"`
    }
    
    // ❌ re-enters itself: fatal error: stack overflow
    …
  14. 14

    NTP steps a box's clock back 400 ms. Every latency computed as time.Since(start) stays sane, while a colleague's end.Unix() - start.Unix() goes negative. What is a time.Time carrying that makes the first one safe?

    Hard

    A time.Time returned by time.Now() carries two clocks — a wall-clock reading and a monotonic one — and Sub, Since and Until use the monotonic reading whenever both operands have it; that clock only ever moves forward.

    func main() {
    	start := time.Now()
    	fmt.Println(start)
    	// 2026-09-19 12:10:25.070834 +0500 +05 m=+0.000883876
    	//                                      ^ the monotonic reading
    …
  15. 15

    A poller written as select { case <-ctx.Done(): return; case <-time.After(30*time.Minute): } grew RSS on Go 1.22 and is flat on Go 1.24, while a second service that creates a time.Ticker per retry and keeps it is wrong on both. What changed, and what did not?

    Hard

    Before Go 1.23 the runtime kept every pending timer alive until it fired, so time.After in a loop pinned a channel and its closure for the full interval; Go 1.23 made unreachable Timers and Tickers collectible, but a ticker you still hold is reachable — Stop is still your job.

    // ❌ a fresh 30-minute timer every iteration. Before Go 1.23 each one stayed
    // alive until it fired; it is still an allocation, and the interval restarts
    // whenever ctx wins the select.
    func pollBad(ctx context.Context) {
    	for {
    		select {
    …
  16. 16

    You have to ingest a 40 GB NDJSON export whose every line is {"type":..., "data":{...}}, where data's shape depends on type, and write the lines you reject back out as NDJSON. io.ReadAll plus json.Unmarshal OOMs, and decoding twice through map[string]any is too slow. What does the stdlib give you?

    Hard

    One json.Decoder over the reader decodes one value at a time in constant memory, and a json.RawMessage field parks the data bytes undecoded until type says which struct they belong in — so every line is parsed exactly once.

    type Envelope struct {
    	Type string          `json:"type"`
    	Data json.RawMessage `json:"data"` // raw bytes until Type is known
    }
    
    type Push struct {
    …