Buffering output can reduce write overhead, but it also changes when an I/O failure becomes visible.

With an unbuffered writer, a call to Write normally reaches the underlying destination immediately. With bufio.Writer, a successful call may only mean that the bytes were accepted into memory. The actual write to a file, socket, pipe, or other destination can happen later, often during Flush.

That distinction creates a common production bug: code checks every apparent write, defers Flush, and still returns success after the final flush fails.

The fix is not complicated, but it requires treating Flush as part of the write operation rather than as optional cleanup.

Buffering moves the error boundary

Consider a small formatter:

func writeReport(dst io.Writer, rows []string) error {
    bw := bufio.NewWriter(dst)

    for _, row := range rows {
        if _, err := fmt.Fprintln(bw, row); err != nil {
            return err
        }
    }

    return bw.Flush()
}

This function has two places where failure can become visible:

  1. a write to the buffered writer can fail, and
  2. the final Flush can fail while forwarding buffered bytes to dst.

The second case is easy to miss because the loop can complete successfully even though some or all buffered output has not yet reached the destination.

The useful mental model is:

application -> bufio.Writer memory -> underlying writer

A successful application write only proves progress as far as the layer that accepted it. If bytes remain buffered, success has not yet propagated through the full chain.

Do not discard the final Flush error

This pattern looks tidy but loses information:

func writeReport(dst io.Writer, rows []string) error {
    bw := bufio.NewWriter(dst)
    defer bw.Flush() // Error is discarded.

    for _, row := range rows {
        if _, err := fmt.Fprintln(bw, row); err != nil {
            return err
        }
    }

    return nil
}

A deferred function still runs, but there is nowhere for the returned Flush error to go. The caller can receive nil even when the destination rejected the buffered data.

When flushing is necessary for correctness, make it an explicit part of the success path:

if err := bw.Flush(); err != nil {
    return fmt.Errorf("flush report: %w", err)
}
return nil

This also makes the ownership rule visible: the function that creates the buffered writer is responsible for ensuring its buffered bytes are forwarded before reporting success.

A write error can appear before the explicit Flush

bufio.Writer does not wait until the final Flush in every case. When its buffer fills, a later write may need to forward buffered bytes to the underlying writer to make room.

That means this check remains necessary:

if _, err := bw.WriteString(line); err != nil {
    return fmt.Errorf("write line: %w", err)
}

Do not assume that checking only the final flush is sufficient.

A robust function checks errors from operations that can return them and then checks the final flush if execution reaches it.

The writer remembers an underlying write failure

A useful property of bufio.Writer is that an underlying write failure is sticky. Once writing to the wrapped destination fails, later writes and Flush report that failure rather than pretending the stream recovered.

This simplifies error handling. You do not need to repeatedly probe the destination or maintain a second error flag merely to remember that the buffered writer has entered a failed state.

For example:

func encodeLines(dst io.Writer, lines []string) error {
    bw := bufio.NewWriter(dst)

    for _, line := range lines {
        if _, err := bw.WriteString(line); err != nil {
            return fmt.Errorf("buffer line: %w", err)
        }
        if err := bw.WriteByte('\n'); err != nil {
            return fmt.Errorf("buffer newline: %w", err)
        }
    }

    if err := bw.Flush(); err != nil {
        return fmt.Errorf("flush lines: %w", err)
    }
    return nil
}

The wrapping adds operation context while %w preserves the original error for errors.Is and errors.As checks by callers.

Be careful when a function already has another error

Sometimes a function can fail during its main work and also during flushing. A named return value can capture a deferred flush failure, but the policy needs to be explicit.

This version preserves the primary error and uses the flush error only when no earlier error exists:

func writeReport(dst io.Writer, rows []string) (err error) {
    bw := bufio.NewWriter(dst)

    defer func() {
        flushErr := bw.Flush()
        if err == nil && flushErr != nil {
            err = fmt.Errorf("flush report: %w", flushErr)
        }
    }()

    for _, row := range rows {
        if _, err := fmt.Fprintln(bw, row); err != nil {
            return fmt.Errorf("write report row: %w", err)
        }
    }

    return nil
}

This is valid when the earlier error is the most important diagnostic. But it deliberately discards a second, distinct flush failure when both occur.

If both failures matter, combine them explicitly instead of silently choosing one. On modern Go versions, errors.Join can preserve multiple independent errors:

func writeReport(dst io.Writer, rows []string) (err error) {
    bw := bufio.NewWriter(dst)

    defer func() {
        if flushErr := bw.Flush(); flushErr != nil {
            err = errors.Join(err, fmt.Errorf("flush report: %w", flushErr))
        }
    }()

    for _, row := range rows {
        if _, writeErr := fmt.Fprintln(bw, row); writeErr != nil {
            return fmt.Errorf("write report row: %w", writeErr)
        }
    }

    return nil
}

Use this only when flushing after the earlier failure is meaningful for the destination and your protocol. Error aggregation is a policy choice, not a requirement to perform every possible cleanup operation after every failure.

Flush is not the same as durable storage

A successful bufio.Writer.Flush means buffered bytes were forwarded to the wrapped io.Writer. It does not make stronger promises than that writer provides.

For a file, for example, flushing the Go user-space buffer is different from asking the operating system to synchronize file contents to stable storage. If an application requires a durability boundary, it needs the appropriate file-level operation and must check that error separately.

Keep the layers distinct:

bufio flush       buffered bytes handed to the wrapped writer
file synchronization  operating system asked for a stronger persistence boundary

Likewise, flushing a buffered network writer does not mean the remote peer processed the bytes. It only advances the data through the local writer contract.

Closing the destination is a separate responsibility

bufio.Writer wraps an io.Writer; it does not generally own or close that destination for you.

If your function opens a file and also creates the buffer, it owns two lifecycle operations:

func createReport(path string, rows []string) (err error) {
    f, err := os.Create(path)
    if err != nil {
        return fmt.Errorf("create report: %w", err)
    }

    bw := bufio.NewWriter(f)

    defer func() {
        err = errors.Join(err, f.Close())
    }()

    for _, row := range rows {
        if _, writeErr := fmt.Fprintln(bw, row); writeErr != nil {
            return fmt.Errorf("write report row: %w", writeErr)
        }
    }

    if err := bw.Flush(); err != nil {
        return fmt.Errorf("flush report: %w", err)
    }

    return nil
}

Here the flush happens before the deferred close on the normal path. That ordering matters: closing the underlying file first would make a later flush unable to deliver buffered bytes.

Whether a close error should be joined with an earlier error depends on the API’s error policy. The important point is to decide rather than accidentally discard it.

Test the failure path with a deliberately failing writer

Happy-path tests cannot prove that late errors are propagated. A tiny test writer makes the boundary deterministic:

type limitWriter struct {
    remaining int
}

func (w *limitWriter) Write(p []byte) (int, error) {
    if w.remaining == 0 {
        return 0, errors.New("destination full")
    }

    n := min(len(p), w.remaining)
    w.remaining -= n
    if n < len(p) {
        return n, errors.New("destination full")
    }
    return n, nil
}

Then force the buffered writer to encounter the error:

func TestWriteReportReturnsFlushError(t *testing.T) {
    dst := &limitWriter{remaining: 3}

    err := writeReport(dst, []string{"alpha"})
    if err == nil {
        t.Fatal("expected write failure")
    }
}

With the default buffer size, the formatted line can fit in memory first, so the destination failure is observed when writeReport calls Flush. This is exactly the late-failure case that a discarded deferred flush would hide.

For more detailed tests, use a writer that records call counts, limits accepted bytes, or returns a sentinel error so the test can verify it with errors.Is.

Choose the buffer size for behavior, not correctness

bufio.NewWriter uses a default buffer. bufio.NewWriterSize lets you request another size:

bw := bufio.NewWriterSize(dst, 64*1024)

Buffer size affects how much data can accumulate before the underlying writer is called. It can influence throughput, latency, and the point at which an error becomes visible.

It should not change the correctness rule. Regardless of size, check write errors and check the final flush.

A larger buffer can postpone an underlying failure until later. A smaller buffer can cause more frequent writes. Measure the real workload before treating a larger buffer as an automatic optimization.

Avoid reusing a writer without understanding Reset

bufio.Writer.Reset redirects a writer to a new destination and clears its current buffered state and error. Unflushed bytes are not automatically preserved for the old destination.

So this is dangerous:

bw.WriteString("pending")
bw.Reset(other) // pending output for the old destination is discarded

If the old output must be delivered, flush it successfully before resetting:

if err := bw.Flush(); err != nil {
    return err
}
bw.Reset(other)

Pooling buffered writers can reduce allocations in some hot paths, but it also makes lifecycle ownership more subtle. Reuse only when profiling justifies it and the flush/reset boundary is easy to audit.

Keep the success condition precise

The central rule is simple: if your function owns a bufio.Writer, it should not report successful output while bytes remain buffered or while the final flush error is unknown.

Check errors from writes because buffer pressure can expose an underlying failure early. Check Flush because a destination failure can remain invisible until the end. Treat close, durability, and remote acknowledgement as separate layers with their own contracts.

Buffering is valuable precisely because it delays work. Reliable code accounts for the other half of that tradeoff: it also delays some failures.