← Runtimes
Runtime walkthrough 7 questions · 6 languages

I/O, buffering, and external state

Printed text appears at once in most programs. This lesson shows where text can wait before it appears.

fmt.Println sends its text out at once. Text waits when you add a writer that collects it in memory, such as a bufio.Writer. That text is sent on only when you call Flush or the writer is full: not at a newline, and not at the end of the program.

Before you begin

Why this matters

Most of the time output appears when you print it, so the times it does not are confusing: a message that shows up late, two messages in the wrong order, or a last line missing when the program exits early. In Go these come from writers you add that collect text, usually a bufio.Writer. Once you know where text waits, you can predict them and fix them.

What this helps you reason about

  • Predict whether a message is on the screen before a slow step runs.
  • Explain why two messages can appear in a different order from the code that wrote them.
  • Spot text that will be lost if the program stops early, and send it out in time.

When you call fmt.Println, you see the text at once, and in Go that model is correct: os.Stdout keeps no text waiting. This lesson asks what changes it: a bufio.Writer added in between, two paths to one output, and a program that exits early.

Already know this? The short version
  • os.Stdout and os.Stderr are *os.File values with no buffer: each Write is usually one write system call, with or without a newline, to a terminal or a pipe.
  • A bufio.Writer (4096 bytes by default) is flushed only by Flush or by filling up; nothing flushes it at a newline or when the program ends.
  • os.Exit, and log.Fatal, which calls it, skip deferred calls, so defer out.Flush() does not run (log.Fatal’s own message survives, because it goes to unbuffered standard error); flush first, or use func main() { os.Exit(run()) } so the deferred Flush runs when run returns. An unrecovered panic in another goroutine also ends the program without running main’s deferred calls.
  • Standard output and standard error lose their relative order when one side goes through a bufio.Writer; flush it before writing a warning.
  • Flush hands bytes to the operating system; (*os.File).Sync asks for a disk write.

Go to the final check· Go to the practice tickets

Words in this lesson (8)
operating system
The software, such as Linux, macOS, or Windows, that runs programs and controls the screen, files, and other devices. Every write a program makes is a request to it.
stream
A destination that receives bytes in order. A program writes to streams; it does not draw on the screen itself.
standard output and standard error
The two output streams every program starts with. Standard output is for normal results; standard error is for messages about problems.
terminal and pipe
A terminal is the window where you type commands. A pipe is a connection that carries one program’s output into another program’s input.
buffer
An area of memory where written text waits inside the program before it is sent on to the operating system.
bufio.Writer
Go’s buffered writer. It holds up to 4096 bytes by default and passes them on only when you call Flush or when it is full.
flush
To send the text waiting in a buffer on to the operating system now. A flush is not a disk write.
os.Exit
Ends the program immediately with an exit code. Deferred calls do not run.

Examples are focused snippets; imports, enclosing functions, or classes may be omitted. C examples use C11; Haskell examples use Haskell 2010. Expand the notes for language-specific depth.

01 / A write call

What does a print call promise?

Here is a useful model: fmt.Println writes to the screen immediately. For most small programs, it predicts exactly what you see.

Underneath, a program does not draw on the screen itself. It asks the operating system to write bytes to a stream, and the operating system delivers them.

Every program starts with two output streams. Standard output is for the program’s normal results; fmt.Println, through os.Stdout, writes there. Standard error is for messages about problems; fmt.Fprintln(os.Stderr, ...) writes there.

The program does not choose where these streams go. Whoever starts the program chooses. A stream may go to a terminal, to a file, or through a pipe into another program.

main.go
fmt.Print("loading... ") // no newline
total := 0
for i := 1; i <= 3; i++ {
    total += i
}
fmt.Println("ready", total)

A write passes text to a stream. The program does not choose where that stream goes.

Coming from Java: Java’s System.out keeps a small buffer, and because it is created with automatic flushing, it flushes at every println; Go’s os.Stdout has no buffer at all.

Precisely: *os.File has no buffer

os.Stdout is an *os.File. Each Write on an *os.File becomes a write system call: a direct request to the operating system. fmt.Print formats its arguments first and then makes one such call, so nothing is held back, whatever the destination.

Experiment

Watch when each write reaches the output panel

os.Stdout is an *os.File, and writes to an *os.File go straight to the operating system. fmt.Print formats the text and makes that write; there is no buffer in between, so a missing newline changes nothing.

Why it matters Before you assume "it prints immediately", ask what sits between your print call and the screen. When nothing does, output appears in statement order.

Predict whether loading... appears before the loop runs, even though fmt.Print adds no newline. Then change fmt.Print to fmt.Println and check whether the timing changes.

package main

import "fmt"

func main() {
    fmt.Print("loading... ")
    total := 0
    for i := 1; i <= 3; i++ {
        total += i
    }
    fmt.Println("ready", total)
}
What to look for

Step past the fmt.Print line and look at the output panel: loading... is already there while the loop is still running. The panel shows standard output as the stepper received it at each stop.

Expected output

loading... ready 6

02 / Buffers

Where does written text wait?

Asking the operating system to write takes time, much more than copying bytes in memory. So many programs collect text in a buffer first: an area of memory where written text waits until there is enough to send at once.

So the model from section 01, that a print writes to the screen immediately, needs one addition: written text can still be inside the program. Anything that does not go through the buffer, such as fmt.Println straight to os.Stdout, can arrive first.

Go’s bufio.Writer is such a buffer. bufio.NewWriter(os.Stdout) holds up to 4096 bytes and passes them to os.Stdout only when it is flushed or full. Programs that print many lines use one for speed.

main.go
out := bufio.NewWriter(os.Stdout)
fmt.Fprintln(out, "1 queued") // waits in out
fmt.Println("2 direct") // goes straight out
out.Flush()

A buffer holds written text inside the program. Until it is sent on, nothing outside the program can see it.

Precisely: a full buffer

When a write does not fit in the space left, bufio.Writer fills the buffer, sends it, and keeps going. A single write larger than the whole buffer, made while the buffer is empty, is passed straight through. Either way, text leaves only at those moments or at Flush.

Experiment

Make a direct write arrive before a buffered one

bufio.NewWriter puts a buffer between your code and os.Stdout. fmt.Fprintln(out, ...) adds text to that buffer; out.Flush() passes it to os.Stdout. fmt.Println writes to os.Stdout directly, so it does not wait.

Why it matters When text reaches one output by two paths and only one path is buffered, lines can come out in a different order from the code. Flush the buffered path before writing on the other one.

Predict the order of the three lines. Then move the first out.Flush() above fmt.Println("2 direct") and predict again.

package main

import (
    "bufio"
    "fmt"
    "os"
)

func main() {
    out := bufio.NewWriter(os.Stdout)
    fmt.Fprintln(out, "1 queued")
    fmt.Println("2 direct")
    out.Flush()
    fmt.Fprintln(out, "3 queued")
    out.Flush()
}
What to look for

Watch the output panel at each stop. 2 direct appears first, at the stop after it is written. 1 queued appears only at the stop after the flush, even though it was written first. After the edit, 1 queued appears first, and the lines come out in code order. In the variables panel, out shows an address, → and bufio.Writer {waiting: "1 queued\n", n: 9, size: 4096} while 1 queued waits: waiting is the text still in the buffer, n counts its bytes, and size is the buffer’s capacity. After the flush, waiting is "" and n is 0.

Expected output

2 direct
1 queued
3 queued

03 / Flushing

What moves text out of a buffer?

To flush a buffer is to send the text waiting in it on to the operating system now. Buffers are flushed in four common ways. The program asks for it: an explicit flush. A newline is written: line buffering. The buffer is full and needs room. And some buffers, in some languages, are flushed when the program exits normally. Which of them apply depends on the language and on the buffer.

bufio.Writer uses only two of them: an explicit Flush and a full buffer. It never flushes at a newline, and nothing flushes it when the program ends. That is why Go code often defers a Flush right after creating the writer.

main.go
out := bufio.NewWriter(os.Stdout)
defer out.Flush() // runs when main returns
fmt.Fprintln(out, "first")
fmt.Fprintln(out, "second")

Flushing moves text from a buffer to the operating system. A bufio.Writer is flushed only by Flush or by filling up.

Coming from Python: Python flushes sys.stdout when the program ends; nothing flushes a bufio.Writer at the end of main except a Flush you wrote.

Precisely: Flush reports errors

Flush returns an error if the underlying write fails, and after an error the writer refuses further writes. A deferred out.Flush() throws that error away; check the result of an explicit Flush when you must know the output was written. Flush hands bytes to the operating system; (*os.File).Sync asks it to write a file to the disk.

04 / Terminals and pipes

Why can the same program print differently in a terminal?

Many buffers change their behavior depending on where output goes. A person at a terminal wants to see each line as soon as it is printed. A program reading from a pipe, or a file being written, does better with fewer, larger writes. So a common choice is line buffering for a terminal and full buffering for a pipe or a file. A fully buffered stream is flushed only when its buffer fills up or when the program asks.

Go does not make this choice. os.Stdout behaves the same for a terminal, a pipe, and a file, and so does a bufio.Writer. A Go program prints with the same timing everywhere; what changes is who reads it.

main.go
info, _ := os.Stdout.Stat()
if info.Mode()&os.ModeCharDevice != 0 {
    fmt.Println("standard output is a terminal")
} else {
    fmt.Println("standard output is a pipe or a file")
}

Where output goes does not change Go’s buffering, but it does in other languages. Do not assume a terminal.

Precisely: the stepper is not a terminal

The stepper runs Go programs under the Delve debugger with standard output connected to a pipe. The check above treats every character device as a terminal, including /dev/null; golang.org/x/term’s IsTerminal is exact.

05 / Two streams

Why do errors and results come out of order?

Standard output and standard error are separate streams, so they can go to different places. Whoever starts a program can send its standard output to a file while standard error still appears in the terminal. That is why results go to standard output and problems to standard error: a program that reads the results never sees the warnings.

The two streams can also go to the same file. Then the order of lines in that file depends on buffering. If standard output is buffered and standard error is not, a warning can appear in the file before results that the program wrote earlier.

Both os.Stdout and os.Stderr are unbuffered, so in a plain Go program lines keep their order even in one file. The order changes when one path is buffered: results written through a bufio.Writer and warnings written straight to os.Stderr. Flush the results before writing a warning.

main.go
out := bufio.NewWriter(os.Stdout)
for _, value := range values {
    if valid(value) {
        fmt.Fprintln(out, convert(value))
    } else {
        out.Flush() // earlier results first
        fmt.Fprintln(os.Stderr, "warning: skipped", value)
    }
}
out.Flush()

Results go to standard output, problems to standard error. Flush buffered results before writing to the other stream.

Precisely: redirecting in a terminal, and the output panel

In a terminal, tool > results.txt runs a program named tool, and > sends its standard output to the file results.txt. tool > run.log 2>&1 sends both streams to run.log. Read 2>&1 one piece at a time: 2 is the number of standard error, > sends it somewhere, and &1 means stream 1, standard output, not a file named 1. So 2>&1 means “send standard error where standard output goes”. The stepper does not show standard error, and it reports a Go program that writes to standard error as an error. The buffers experiment therefore uses fmt.Println as the unbuffered path; os.Stderr behaves the same way.

06 / Lost output

What happens to waiting text when a program stops early?

Text in a buffer is still inside the program. If the program stops before the buffer is flushed, that text is gone. This happens when a program crashes, when the operating system stops it, and when it ends through a call that skips the normal cleanup.

In Go, os.Exit ends the program immediately. Deferred calls do not run, so defer out.Flush() does not help. log.Fatal calls os.Exit too. Its own message still appears, because the log package writes it to standard error, which has no buffer, before calling os.Exit; text waiting in a bufio.Writer on standard output is lost. A panic that is not recovered runs the deferred calls of the goroutine that panicked, and then the program stops. So if the panic happens in another goroutine, the deferred calls in main never run, and defer out.Flush() in main is skipped.

A common pattern keeps the deferred Flush working: func main() { os.Exit(run()) }. The run function defers its Flush and returns an exit code. The deferred Flush runs when run returns, before os.Exit is called.

main.go
out := bufio.NewWriter(os.Stdout)
defer out.Flush() // skipped by os.Exit
fmt.Fprintln(out, "saved")
out.Flush()
fmt.Fprintln(out, "lost")
os.Exit(0)

Buffered text survives only if something flushes it. Flush before os.Exit, return from main, or call os.Exit only after a run function has returned.

Coming from Java: Like System.exit, which skips finally blocks, os.Exit skips deferred calls, and log.Fatal calls os.Exit.

Precisely: fatal errors, and a panic in another goroutine

Text already passed to os.Stdout has left the program and survives a later crash. Two other failures skip main’s deferred calls.

A fatal error from the runtime, such as concurrent writes to a map or running out of memory, is not a panic. recover cannot catch it, and the program stops without running any deferred calls in any goroutine.

An unrecovered panic runs the deferred calls of the goroutine that panicked, and then the program stops. Observed with Go 1.27, in the stepper and with go run: main wrote a line into a bufio.Writer, deferred out.Flush(), and waited while a second goroutine panicked. The program stopped with the panic message on standard error, and the line never appeared.

Experiment

Exit while text is still in a buffer

os.Exit ends the program immediately and does not run deferred calls, so the deferred out.Flush() never runs and lost stays in the bufio buffer. With return, main finishes normally, the deferred Flush runs, and lost appears.

Why it matters In Go, defer out.Flush() runs on a normal return but not on os.Exit. Flush before calling os.Exit, or return from main instead.

Predict whether lost is printed, given the deferred out.Flush(). Then replace os.Exit(0) with return and predict again.

package main

import (
    "bufio"
    "fmt"
    "os"
)

func main() {
    out := bufio.NewWriter(os.Stdout)
    defer out.Flush()
    fmt.Fprintln(out, "saved")
    out.Flush()
    fmt.Fprintln(out, "lost")
    os.Exit(0)
}
What to look for

Watch the output panel. saved appears at the stop after the flush. lost never appears, not even after the run has ended. After the edit, lost appears only when the run has ended. The stepper does not show a separate stop for the deferred Flush.

Expected output

saved

07 / Try it

Can you predict what reaches the output?

Predict the output before revealing the answer. Follow out’s buffer and the direct writes separately.

main.go
package main

import (
	"bufio"
	"fmt"
	"os"
)

func main() {
	out := bufio.NewWriter(os.Stdout)
	defer out.Flush()
	fmt.Fprintln(out, "one")
	fmt.Println("two")
	out.Flush()
	fmt.Fprintln(out, "three")
	fmt.Println("four")
	os.Exit(0)
}

What does this print? Does every line appear?

Reveal the reasoning

two, one, four. one waits in out’s buffer, and fmt.Println sends two straight to os.Stdout, so two appears first. out.Flush() then sends one. three waits in the buffer again, four goes straight out, and os.Exit ends the program without running the deferred Flush, so three is lost.

Practice tickets

  1. IO-101 · Show progress while photos copy → Free
  2. IO-102 · Keep the move journal after a crash → Sign in · Pro
  3. IO-103 · Separate results from warnings → Sign in · Pro

Keep exploring

Stack and heap →

Primary references