skip to content

An io.Writer returns (len(p), nil) though only part of p reached the destination. What breaks?

level: seniorimportance: should knowfreq 38%

answer

  1. n is a promise, not a hint
  2. a short write demands an error
  3. io.Copy trusts the count you return
  4. the sentinel is io.ErrShortWrite

basics

~20 s

io.Writer's contract requires a non-nil error whenever Write returns n < len(p). Reporting len(p) after a partial write makes every caller, including io.Copy, believe the bytes landed, so data is lost silently and the operation reports success.

solid answer

~50 s

`Write` must return the number of bytes it actually consumed from `p`, and must return a non-nil error whenever that number is less than `len(p)`. Callers rely on both halves. `io.Copy` compares the bytes it read with the bytes the destination reported and returns `io.ErrShortWrite` if they disagree -- that check is the safety net that a lying implementation defeats. Claiming `len(p)` with a nil error means truncated files and truncated HTTP responses that no error path ever reports; the symptom surfaces days later as a checksum mismatch or a parse failure downstream, with nothing in the logs. The rule is also the deliberate asymmetry with `Read`, which *may* return a short count with a nil error because the caller is expected to loop. A writer has no such licence: either finish `p`, or say honestly how far you got and why.

code

go · 14 lines
go
type capWriter struct {
	buf  []byte
	used int
}

func (w *capWriter) Write(p []byte) (int, error) {
	n := copy(w.buf[w.used:], p)
	w.used += n
	if n < len(p) {
		// contract: a short count must carry a non-nil error
		return n, io.ErrShortWrite
	}
	return n, nil
}

go deeper

for a junior

Learn the rule itself: Write reports how many bytes of p it took, and if that is fewer than len(p) it must also return a non-nil error. Never invent the count.

for a middle

Explain the asymmetry -- a short Read with a nil error is legal, a short Write is not -- and show the internal loop a writer over a partial destination needs.

for a senior

Diagnose the production shape: success everywhere, truncated data downstream, no error anywhere. Say how you would confirm it with byte counts and checksums rather than trusting a nil error.

for a principal

Treat exact return-value semantics as part of the API you sign off. Once other teams' code trusts your counts, a writer that rounds n up turns a local bug into silent data loss across every consumer.

## The clause the implementation broke `io.Writer` is documented tightly: `Write` writes `len(p)` bytes from `p` to the underlying data stream, returns the number of bytes written (`0 <= n <= len(p)`) and any error that caused the write to stop early, and **must return a non-nil error if it returns `n < len(p)`**. So `n` is not advisory. It is a promise about how much of the caller's data the writer took responsibility for, and a short `n` is only legal when accompanied by an explanation. ## Why the asymmetry with Read exists `Read` is allowed to return fewer bytes than the slice can hold with a nil error, because a reader can only give you what has arrived and the caller is expected to loop. Reversing that for `Write` would push the same looping duty onto every caller of every writer -- and, worse, would make a short write indistinguishable from a full one when the destination is genuinely full or broken. Go instead demands that a writer either consume all of `p` or report why it could not. Implementations that talk to something with its own short-write semantics -- a raw file descriptor, a fixed-size destination -- are the ones that must contain the retry loop internally. ## What the lie costs Suppose a writer wraps a destination that accepted only half of `p`, and returns `(len(p), nil)` anyway: - `io.Copy` sums the counts the destination reports. It reads `nr` bytes and writes them; when the writer returns `nw != nr` with a nil error, `io.Copy` stops and returns `io.ErrShortWrite`. That is the contract catching a *correctly* short writer. A writer that inflates `n` slips past this check entirely. - The copy therefore finishes with a nil error and a byte count that matches the source. Every layer above -- the handler that logs "upload complete", the job that marks the row processed, the caller that deletes the temporary file -- believes it. - The damage is discovered much later and far away: a truncated object in storage, a decoder that hits an unexpected end of input, a hash that no longer matches. There is no stack trace and nothing correlating it to the write, because the write reported success. An incorrect count is worse than an error, because an error is handled and a wrong number is trusted. ## Getting it right in your own implementation Two shapes cover almost every case. **Loop until the destination has taken everything.** If the thing underneath can accept partial data, keep going and return only when `p` is exhausted or something fails: ```go func (w *frameWriter) Write(p []byte) (int, error) { written := 0 for written < len(p) { n, err := w.dst.Write(p[written:]) written += n if err != nil { return written, err } } return written, nil } ``` **Report honestly when you cannot finish.** If the destination is bounded and full, return the real count with an error -- `io.ErrShortWrite` is the standard sentinel for exactly this, and a domain-specific error is fine too. The one thing you must never do is round `n` up to `len(p)`. Also resist the mirror-image mistake in wrappers: a writer that decorates another must return the inner writer's `n`, not `len(p)`, or it re-introduces the same lie one layer up. ## Testing it Contract bugs of this kind hide from ordinary tests, which use small payloads that always fit in one call. Drive the implementation deliberately: - Write through a destination with a hard capacity, and assert that a partial write yields a non-nil error and a count equal to what was really stored. - For readers, `iotest.TestReader(r, content)` exercises an `io.Reader` implementation against the contract with reads of varying sizes and reports the first clause it breaks, and `iotest.OneByteReader(r)` hands out at most one byte per call, which flushes out consumers that quietly assume a full slice. - Assert on total bytes and on a checksum of the destination, never just on the returned error being nil. The whole failure mode here is a nil error over missing data. ## The review heuristic When you review a `Write` method, read the return statements first. Any `return len(p), nil` that is not preceded by proof that all of `p` was consumed is a defect, and any `return n, nil` where `n` could be less than `len(p)` is the same defect from the other direction. Those two lines are where this bug lives.

  • Why is io.Writer forbidden a short count with a nil error when io.Reader is allowed one?
    A reader can only hand over what has arrived, so callers loop by design. A writer is being *given* the data and is the party that knows whether it took all of it; allowing a silent short write would force every caller to loop and would make "full" indistinguishable from "broken". Go puts the loop inside the writer instead.
  • How would you exercise an io.Reader implementation against the contract in tests?
    `iotest.TestReader(r, content)` from `testing/iotest` drives a reader with reads of different sizes and checks that it returns the expected bytes and honours the interface's rules. Pair it with `iotest.OneByteReader`, which delivers one byte per call, to prove your consuming code copes with the smallest legal behaviour.
  • What should a wrapping Writer return when the writer it delegates to reports a short count?
    Exactly what the inner writer reported -- its `n` and its error -- adjusted only for any bytes the wrapper itself added or removed. Substituting `len(p)` because the wrapper "accepted" the data hides the truncation from every layer above it.

saying these in an interview costs you the question

  • Returns len(p) regardless of what the destination accepted
  • Returns a short n with a nil error and expects callers to retry
  • Thinks io.Copy will detect any lost bytes for you
  • A wrapper returns len(p) instead of the inner writer's n
  • Tests only small payloads that always fit one call