🔌 Analogy
fmt is the label-maker: it turns values into readable text. io.Reader and io.Writer are the universal plugs — every source of bytes (a file, a socket, a string) is a Reader, every sink (a file, the terminal, a buffer) is a Writer, and because the plugs are standard, anything connects to anything. Master those two interfaces and most of the standard library suddenly fits together.
Printing with fmt
fmt has three families, and the suffix tells you the destination: the bare name (Print, Println) writes to os.Stdout; the S prefix (Sprintf) returns a string; the F prefix (Fprintf) writes to any io.Writer you pass. Within each family, Printf takes a format string of verbs, Println space-separates its arguments and adds a newline, and Print just concatenates.
package main
import (
"fmt"
"os"
)
type Point struct{ X, Y int }
func main() {
p := Point{1, 2}
fmt.Printf("%v\n", p) // {1 2} — default
fmt.Printf("%+v\n", p) // {X:1 Y:2} — with field names
fmt.Printf("%#v\n", p) // main.Point{X:1, Y:2} — Go syntax
fmt.Printf("%T\n", p) // main.Point — the type
fmt.Printf("%d %s %q %.2f %t\n", 42, "hi", "hi", 3.14159, true)
// 42 hi "hi" 3.14 true
s := fmt.Sprintf("(%d, %d)", p.X, p.Y) // build a string, don't print
fmt.Println("built:", s)
fmt.Fprintln(os.Stderr, "this line goes to stderr, not stdout")
}
The verb reference
You can get a long way with a handful of verbs. These are the ones worth memorizing:
| Verb | Prints | Example → output |
|---|---|---|
%v | the value, default format | {1 2} |
%+v | struct with field names | {X:1 Y:2} |
%#v | Go-syntax representation | main.Point{X:1, Y:2} |
%T | the type of the value | main.Point |
%d | integer (base 10) | 42 |
%b %o %x | binary / octal / hex | 101010 52 2a |
%f %.2f %e %g | float: fixed / precision / scientific / compact | 3.14 |
%s | string or []byte | hi |
%q | double-quoted, escaped string | "hi" |
%c %U | rune as character / Unicode point | 世 U+4E16 |
%p | pointer address | 0xc000012345 |
%t | boolean | true |
%w | wrap an error (only in Errorf) | — |
Width, precision, and flags
Between the % and the verb you can put flags, a width, and a precision — the same grammar as C’s printf. Width pads the field; precision controls decimals (for floats) or max length (for strings). A - left-aligns, 0 zero-pads, + always shows the sign, and * reads the width/precision from an argument. This is what makes aligned, tabular output possible:
package main
import "fmt"
func main() {
items := []struct {
name string
price float64
}{{"Coffee", 3.5}, {"Sandwich", 12}, {"Tea", 2.25}}
for _, it := range items {
// %-10s left-aligns in a 10-wide field; %8.2f is 8 wide, 2 decimals
fmt.Printf("%-10s $%8.2f\n", it.name, it.price)
}
fmt.Printf("|%6d|%-6d|%06d|\n", 42, 42, 42) // | 42|42 |000042|
fmt.Printf("padded hex: %#04x\n", 255) // padded hex: 0x00ff
// %*d takes the width from an argument
for _, w := range []int{4, 8} {
fmt.Printf("%*d\n", w, 7)
}
}
🧪 go vet checks your format strings
A wrong verb (%d for a string, too few arguments) compiles fine but prints garbage like %!d(string=hi) at runtime. go vet — which go test runs automatically — statically checks Printf-style calls and flags the mismatch before it ships. If you write your own formatting helper, name its variadic Printf-style wrapper accordingly and vet will check it too.
Stringer: controlling how your type prints
When fmt formats a value with %v, %s, or Println, it first checks whether the type implements fmt.Stringer — a single method String() string. If so, fmt calls it. This is the idiomatic way to give a type a human-readable form, and it’s why time.Duration prints as 1h30m0s instead of a raw nanosecond count.
package main
import (
"fmt"
"strings"
)
// Celsius controls its own display via Stringer.
type Celsius float64
func (c Celsius) String() string { return fmt.Sprintf("%.1f°C", float64(c)) }
// Level uses Stringer to turn an enum into a name.
type Level int
const (
Debug Level = iota
Info
Error
)
func (l Level) String() string {
return [...]string{"DEBUG", "INFO", "ERROR"}[l]
}
func main() {
fmt.Println("temp:", Celsius(36.6)) // temp: 36.6°C — String() called
fmt.Printf("%v / %v / %v\n", Debug, Info, Error)
// Stringer composes: a slice of Stringers prints each via String()
levels := []Level{Info, Error, Debug}
fmt.Println(strings.Trim(fmt.Sprint(levels), "[]")) // INFO ERROR DEBUG
}
⚠️ A String() that calls fmt on itself recurses forever
If String() formats the receiver with %v (or Sprint(c)), fmt calls String() again — infinite recursion, then a stack overflow. Convert to the underlying type first: fmt.Sprintf("%.1f°C", float64(c)), not fmt.Sprintf("%v°C", c). The same trap applies to a type’s Error() string method. For Go-syntax output (%#v) implement GoStringer; for full control over flags and width implement fmt.Formatter.
The io interfaces
Two one-method interfaces underpin all of Go’s I/O:
type Reader interface { Read(p []byte) (n int, err error) }
type Writer interface { Write(p []byte) (n int, err error) }
Read fills the caller’s buffer and reports how many bytes it got (plus io.EOF when the stream ends); Write consumes a buffer. That’s the entire contract. Because files, network connections, strings.Reader, bytes.Buffer, os.Stdout, gzip streams, and HTTP bodies all satisfy these, anything that reads can be connected to anything that writes:
graph LR SRC["any source<br/>file · socket · string · gzip"] -->|"Read(p []byte)"| R["io.Reader"] R -->|"io.Copy"| W["io.Writer"] W -->|"Write(p []byte)"| DST["any sink<br/>file · terminal · buffer · HTTP"]
The payoff is io.Copy(dst, src): a single function that streams from any Reader to any Writer in a fixed-size buffer, never loading the whole stream into memory. Around it, the io package ships a small kit of adapters — io.MultiWriter (tee to several sinks at once), io.TeeReader (read while copying a side-channel), io.LimitReader (cap how much is read), and io.ReadAll (slurp into memory when you really do want the whole thing):
package main
import (
"bytes"
"fmt"
"io"
"os"
"strings"
)
func main() {
// io.Copy: any Reader -> any Writer, streamed.
src := strings.NewReader("hello, io\n")
var dst strings.Builder // strings.Builder is an io.Writer
n, _ := io.Copy(&dst, src)
fmt.Printf("copied %d bytes: %q\n", n, dst.String())
// io.MultiWriter: write once, land in several places (e.g. screen + log buffer).
var logBuf bytes.Buffer
w := io.MultiWriter(os.Stdout, &logBuf)
fmt.Fprintln(w, "goes to stdout AND the buffer")
fmt.Printf("buffer captured %d bytes\n", logBuf.Len())
// io.LimitReader: read at most N bytes from a longer stream.
limited := io.LimitReader(strings.NewReader("abcdefghij"), 4)
got, _ := io.ReadAll(limited)
fmt.Printf("limited read: %q\n", got) // "abcd"
}
Buffering with bufio
A raw Read/Write can mean one syscall per call — fine for big chunks, ruinous for byte-at-a-time work. bufio wraps a Reader or Writer and batches the underlying I/O. The most common face of it is bufio.Scanner, the idiomatic way to read input line by line (or word by word, via Split):
package main
import (
"bufio"
"fmt"
"strings"
)
func main() {
input := "first line\nsecond line\nthird line"
// Scan line by line. In a real program the source is os.Stdin or a file;
// any io.Reader works, so we use a strings.Reader here for a deterministic demo.
sc := bufio.NewScanner(strings.NewReader(input))
n := 0
for sc.Scan() {
n++
fmt.Printf("%d: %q\n", n, sc.Text())
}
// Switch the split function to count words instead of lines.
sc2 := bufio.NewScanner(strings.NewReader(input))
sc2.Split(bufio.ScanWords)
words := 0
for sc2.Scan() {
words++
}
fmt.Println("words:", words) // 6
}
In production you would read os.Stdin the same way: bufio.NewScanner(os.Stdin). For output, wrap with bufio.NewWriter(f) and remember to Flush() — buffered bytes sit in memory until you do.
Under the hood: why everything is a Writer
fmt.Println is just fmt.Fprintln(os.Stdout, ...), and os.Stdout is an ordinary *os.File that happens to satisfy io.Writer. There is nothing special about “the screen” — it’s one Writer among many. That’s why the same fmt.Fprintf(w, ...) call can target a file, a network socket, an http.ResponseWriter, a bytes.Buffer, a gzip writer, or io.Discard (the /dev/null of writers) without the calling code knowing or caring. Internally fmt reuses a pooled buffer to build the output, then makes a single Write to the destination — so formatting cost is paid in memory, and the sink only sees finished bytes.
When to use what
| You want to… | Reach for |
|---|---|
| Print to the screen | fmt.Println / fmt.Printf |
| Build a string in memory | fmt.Sprintf (or strings.Builder in a loop) |
| Write to a file/socket/response | fmt.Fprintf(w, …) with an io.Writer |
| Control how your type prints | implement String() string (fmt.Stringer) |
| Copy a stream | io.Copy(dst, src) |
| Send output to several sinks | io.MultiWriter(a, b, …) |
| Read input line by line | bufio.NewScanner(r) |
| Slurp a whole stream | io.ReadAll(r) (mind the memory) |
| Discard output | io.Discard |
🐹 Accept io.Reader/Writer; return concrete types
The Go proverb is “accept interfaces, return structs.” A function that produces text should take an io.Writer so the caller chooses the destination; a function that consumes input should take an io.Reader. Keep your own functions at that altitude and they compose with the entire standard library for free — testable with a bytes.Buffer, retargetable to a file or a socket, with zero changes. Don’t hard-code os.Stdout deep in your logic.
See also
- strings & bytes — the text toolkit,
strings.Builder, and thebytesmirror (bytes.Bufferis anio.Writer). - encoding/json —
Encoder/Decoderare built directly onio.Writer/io.Reader. - interfaces — why one-method interfaces like Reader/Writer are the backbone of Go’s design.
- errors —
%wwrapping and theError() stringmethod (a sibling of Stringer). - files & os —
*os.Fileis the canonical Reader and Writer.
Next: slicing and building text — strings & bytes.
Related topics
The text toolkit — searching and transforming with the strings package, O(n) assembly via strings.Builder, the parallel bytes package and bytes.Buffer, strconv for number⇄string conversion, and the unicode/utf8 view of multibyte text.
essentialsencoding/jsonTurning Go values into JSON and back — Marshal/Unmarshal, struct tags and omitempty, decoding into structs vs maps, streaming Encoder/Decoder, custom Marshaler/Unmarshaler, and json.RawMessage for deferred decoding.
Check your understanding
Score: 0 / 51. What's the difference between %v, %+v and %#v in fmt?
%v is the default; %+v adds struct field names ({X:1 Y:2}); %#v prints Go-syntax (main.Point{X:1, Y:2}); and %T prints the type itself.
2. Why are io.Reader and io.Writer so central to Go?
Reader has just Read([]byte) and Writer just Write([]byte). Because so many types implement them, io.Copy can stream from any source to any destination — files, sockets, strings, buffers.
3. How does fmt know to print a custom type as "36.6°C"?
If a value's type has a `String() string` method (the fmt.Stringer interface), fmt uses it for %v, %s and Println. Implement it to control how your type prints.
4. What does `fmt.Errorf("...: %w", err)` do that `%v` does not?
%w embeds the original error in the returned error's chain, so errors.Is/As can unwrap and match it. %v only formats the text, losing the wrapped error for matching.
5. A function should produce text that might go to the screen, a file, or a test buffer. What should it take?
Accepting an io.Writer lets the same code write to os.Stdout, a file, an http.ResponseWriter, or a bytes.Buffer in a test — no changes. That decoupling is the whole point of the interface.
Comments
Sign in with GitHub to join the discussion.