I/O, Files and the Filesystem

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Go 1.27 (go1.27.2), checked on 2026-10-18; every go.mod shown has the line go 1.27. Go models all input and output with a handful of tiny interfaces, io.Reader and io.Writer above all, and the standard library is a large collection of types that implement them or wrap them: files, network connections, compressors, buffers, hashes, HTTP bodies, test doubles. Learn the two interfaces, their contract and the half dozen adapters in package io, and most of the rest of the library reads like a small variation on the same idea. This page covers the interfaces and helpers, buffered I/O with bufio, files and directories with os, the path and path/filepath packages, the io/fs abstraction (including embed.FS, os.DirFS and os.CopyFS), the traversal-resistant os.Root type, and the migration from the deprecated io/ioutil.

It builds on Interfaces (the interfaces here are the best example of small, implicit interfaces), Error Handling (*fs.PathError, errors.Is(err, fs.ErrNotExist) and keeping Close errors) and Functions, Closures and Defer. The neighbouring standard-library pages are fmt, Strings and Text Processing (the Fprintf and Fscan families take readers and writers), encoding/json (Encoder and Decoder stream over readers and writers), net/http Clients and Servers (bodies are readers; http.FS serves an fs.FS), Command-Line Programs (standard streams and exit codes), Directives, Code Generation and Embedding (the //go:embed directive itself) and Testing. For other languages see Rust I/O, Files, Processes and Environment (Read, Write, BufRead), C input, output and files, Java I/O and files and Python files and context managers.

Every example was compiled, vetted with go vet and run with Go 1.27.2 on macOS (arm64); the output shown is the real output, and the compiler and vet messages are copied from real runs. Anything whose result depends on the platform is called out: error text for failed system calls (open versus openat), permission bits, path separators and Windows behaviour. Examples create their files in a temporary directory that they remove, and print $DIR or $BASE in place of its random name. The go.mod of the example module is:

module example.com/shelf

go 1.27

The small interfaces of package io

Package io is mostly interface declarations. Each interface has one or two methods, and richer ones are built by embedding the small ones.

Interface Methods Implemented by (examples)

io.Reader

Read(p []byte) (n int, err error)

*os.File, *bytes.Buffer, *strings.Reader, *bufio.Reader, *gzip.Reader, net.Conn, http.Request.Body

io.Writer

Write(p []byte) (n int, err error)

*os.File, *bytes.Buffer, *strings.Builder, *bufio.Writer, *gzip.Writer, net.Conn, http.ResponseWriter, hash.Hash

io.Closer

Close() error

files, connections, pipes, response bodies

io.Seeker

Seek(offset int64, whence int) (int64, error)

*os.File, *bytes.Reader, *strings.Reader, *io.SectionReader

io.ReaderAt

ReadAt(p []byte, off int64) (n int, err error)

*os.File, *bytes.Reader, *strings.Reader, *io.SectionReader

io.WriterAt

WriteAt(p []byte, off int64) (n int, err error)

*os.File, *io.OffsetWriter

io.ReaderFrom

ReadFrom(r Reader) (n int64, err error)

*os.File, *bytes.Buffer, *bufio.Writer, io.Discard

io.WriterTo

WriteTo(w Writer) (n int64, err error)

*os.File, *bytes.Buffer, *bytes.Reader, *strings.Reader, *bufio.Reader

io.ByteReader, io.ByteWriter, io.ByteScanner

ReadByte, WriteByte, ReadByte + UnreadByte

*bufio.Reader, *bytes.Buffer, *strings.Reader

io.RuneReader, io.RuneScanner

ReadRune, ReadRune + UnreadRune

*bufio.Reader, *bytes.Buffer, *strings.Reader

io.StringWriter

WriteString(s string) (n int, err error)

*os.File, *bytes.Buffer, *strings.Builder, *bufio.Writer

The combined interfaces are plain embeddings: ReadWriter, ReadCloser, WriteCloser, ReadWriteCloser, ReadSeeker, ReadSeekCloser (Go 1.16), WriteSeeker and ReadWriteSeeker. The constants io.SeekStart, io.SeekCurrent and io.SeekEnd (0, 1, 2) are the whence values for Seek. The picture below shows how the composite interfaces are built from the small ones; an *os.File satisfies every one of them.

classDiagram class Reader { <<interface>> Read(p []byte) (int, error) } class Writer { <<interface>> Write(p []byte) (int, error) } class Closer { <<interface>> Close() error } class Seeker { <<interface>> Seek(offset int64, whence int) (int64, error) } class ReadWriter class ReadCloser class WriteCloser class ReadWriteCloser class ReadSeeker class ReadWriteSeeker Reader <|-- ReadWriter Writer <|-- ReadWriter Reader <|-- ReadCloser Closer <|-- ReadCloser Writer <|-- WriteCloser Closer <|-- WriteCloser ReadCloser <|-- ReadWriteCloser WriteCloser <|-- ReadWriteCloser Reader <|-- ReadSeeker Seeker <|-- ReadSeeker ReadSeeker <|-- ReadWriteSeeker Writer <|-- ReadWriteSeeker

Two consequences of this design are worth stating early, because the rest of the page is an application of them. First, a function that needs to read should accept an io.Reader, not a file name or an *os.File: it then works on files, network connections, in-memory strings and test doubles, and it can be tested without touching the disk. Second, the standard library wraps readers and writers in other readers and writers. A gzip reader wraps a file; a JSON decoder wraps the gzip reader; a counting writer wraps a file. The result is a pipeline assembled from independent parts, with no part knowing what is on either side of it (the diagram in A reader chain shows one).

The Read contract

Read is the one method whose contract is easy to get wrong, on the calling side as much as in an implementation. The documentation of io.Reader states the rules, which can be summarised as follows:

  • Read(p) reads up to len(p) bytes and returns how many it stored in n. It may return fewer than len(p) even when more data will arrive later; it conventionally returns what is available instead of blocking for a full buffer.

  • When the stream ends, Read returns io.EOF. It may return the final bytes together with io.EOF in the same call (n > 0, err == io.EOF), or return the bytes with a nil error and report 0, io.EOF on the next call. Both are legal.

  • Callers must process the n > 0 bytes before looking at err. Code that handles the error first loses the last chunk of data from readers that return data and io.EOF together.

  • A return of 0, nil means nothing happened; it is not end of file. Implementations are discouraged from doing it, and callers should simply call Read again. Many wrappers (bufio.Reader, for one) give up with io.ErrNoProgress after 100 consecutive empty reads.

  • io.EOF is the only error that means "normal end". A Read must return io.EOF itself, never an error that wraps it, because callers compare with ==. Any other error is a failure. io.ErrUnexpectedEOF is what helpers such as io.ReadFull return when the stream ends in the middle of a structure.

  • Implementations must not retain p after returning, and a call with len(p) == 0 should return 0.

The same rules apply to Write: it must return a non-nil error if it writes fewer than len(p) bytes, must not modify p and must not retain it. io.ErrShortWrite is the standard error for a writer that accepted fewer bytes than it was given without reporting why.

The first example implements a wrapper reader, the unit from which chains are built, and then shows how the same data is delivered through three different readers: one that returns what is available, one that hands out a single byte per call and one that returns the last bytes together with io.EOF. The last two come from package testing/iotest, which exists to test the callers of readers. A correct consumer produces identical results for all three.

package main

import (
	"fmt"
	"io"
	"strings"
	"testing/iotest"
	"unicode"
)

// upperReader upper-cases everything read through it.
type upperReader struct {
	r io.Reader
}

func (u upperReader) Read(p []byte) (int, error) {
	n, err := u.r.Read(p)
	for i := 0; i < n; i++ {
		p[i] = byte(unicode.ToUpper(rune(p[i])))
	}
	return n, err // report n > 0 and err together, exactly as the inner reader did
}

func dump(label string, r io.Reader) {
	fmt.Println(label)
	buf := make([]byte, 8)
	for {
		n, err := r.Read(buf)
		fmt.Printf("  n=%d err=%v data=%q\n", n, err, buf[:n])
		if err != nil {
			return
		}
	}
}

func main() {
	title := "Go in Practice"
	dump("plain strings.Reader:", strings.NewReader(title))
	dump("upperReader:", upperReader{strings.NewReader(title)})
	dump("data and EOF in the same call:", upperReader{iotest.DataErrReader(strings.NewReader(title))})
	dump("one byte at a time:", iotest.OneByteReader(strings.NewReader("ISBN")))
}
plain strings.Reader:
  n=8 err=<nil> data="Go in Pr"
  n=6 err=<nil> data="actice"
  n=0 err=EOF data=""
upperReader:
  n=8 err=<nil> data="GO IN PR"
  n=6 err=<nil> data="ACTICE"
  n=0 err=EOF data=""
data and EOF in the same call:
  n=8 err=<nil> data="GO IN PR"
  n=6 err=EOF data="ACTICE"
one byte at a time:
  n=1 err=<nil> data="I"
  n=1 err=<nil> data="S"
  n=1 err=<nil> data="B"
  n=1 err=<nil> data="N"
  n=0 err=EOF data=""

The wrapper converts only ASCII letters correctly (unicode.ToUpper applied to a single byte); a Unicode-aware wrapper would have to deal with multi-byte runes that straddle two Read calls, which is the usual reason such wrappers use bufio.Reader.ReadRune or golang.org/x/text/transform instead of working on raw bytes.

Closers, seekers and positional I/O

io.Closer is the other interface that deserves a careful look. By its own contract the behaviour of a second Close is undefined; *os.File returns os.ErrClosed (file already closed) and so do most standard types, but a type you write must decide. Whether Close of a wrapper closes the thing it wraps is also a per-type decision: *gzip.Reader and *gzip.Writer do not close the underlying file (gzip.Writer.Close flushes pending data and writes the trailer, but leaves the underlying writer open), *bufio.Writer has no Close at all, and io.NopCloser exists to give a Reader a no-op Close where an io.ReadCloser is required.

io.Seeker moves the read or write position. Seek(offset, whence) returns the new absolute offset; seeking to a negative position is an error, while seeking past the end is allowed for files and may be allowed for other types (writing there creates a hole). io.ReaderAt and io.WriterAt instead take the offset in every call, do not use or change the file position and, for a file, are safe to call from several goroutines at once. A reader implementing ReaderAt can serve many concurrent readers through io.SectionReader (below).

package main

import (
	"fmt"
	"io"
	"os"
)

func main() {
	f, _ := os.CreateTemp("", "seek-*")
	defer os.Remove(f.Name())
	defer f.Close()

	f.WriteString("0123456789")
	pos, _ := f.Seek(0, io.SeekCurrent)
	fmt.Println("position after writing:", pos)

	// ReadAt ignores and does not move the file offset; it is safe to call concurrently.
	buf := make([]byte, 4)
	n, err := f.ReadAt(buf, 3)
	fmt.Printf("ReadAt(3): %q n=%d err=%v\n", buf[:n], n, err)
	n, err = f.ReadAt(buf, 8)
	fmt.Printf("ReadAt(8): %q n=%d err=%v\n", buf[:n], n, err)

	// Seek moves the offset used by Read and Write.
	f.Seek(-3, io.SeekEnd)
	rest, _ := io.ReadAll(f)
	fmt.Printf("last three bytes: %q\n", rest)

	// WriteAt writes at an offset without touching the file offset.
	f.WriteAt([]byte("AB"), 1)
	f.Seek(0, io.SeekStart)
	all, _ := io.ReadAll(f)
	fmt.Printf("after WriteAt: %s\n", all)

	// Truncate shrinks (or extends with zero bytes) to the given size.
	f.Truncate(4)
	st, _ := f.Stat()
	fmt.Println("size after Truncate(4):", st.Size())

	// Writing past the end leaves a hole filled with zero bytes.
	f.WriteAt([]byte("Z"), 6)
	f.Seek(0, io.SeekStart)
	all, _ = io.ReadAll(f)
	fmt.Printf("%q\n", all)
}
position after writing: 10
ReadAt(3): "3456" n=4 err=<nil>
ReadAt(8): "89" n=2 err=EOF
last three bytes: "789"
after WriteAt: 0AB3456789
size after Truncate(4): 4
"0AB3\x00\x00Z"

Source: os.File and io.Seeker

ReadAt follows a stricter rule than Read: when it returns fewer than len(p) bytes it must return a non-nil error explaining why, which is why the second call above reports io.EOF together with the two bytes it did read.

Helpers in package io

Package io also holds a small set of functions and adapters. They do the work that otherwise gets written over and over, with fewer bugs than a hand-written loop.

Function or type What it does

io.Copy(dst, src)

Copies from src to dst until io.EOF or an error and returns the byte count. A nil error means EOF was reached normally (io.EOF is not returned). Uses src.WriteTo if the source has one, otherwise dst.ReadFrom if the destination has one, otherwise a 32 KiB buffer loop.

io.CopyN(dst, src, n)

Copies exactly n bytes. Returns io.EOF if the source ended first.

io.CopyBuffer(dst, src, buf)

Like Copy with a buffer you supply (panics on an empty buffer); the buffer is not used when WriterTo or ReaderFrom applies.

io.ReadAll(r)

Reads to EOF and returns the bytes; a nil error means EOF was reached. In Go 1.26 it allocates about half as much and is about twice as fast for larger inputs.

io.ReadFull(r, buf)

Fills buf completely. io.EOF if no bytes were read, io.ErrUnexpectedEOF if some were.

io.ReadAtLeast(r, buf, min)

Reads until at least min bytes; io.ErrShortBuffer if len(buf) < min.

io.WriteString(w, s)

Writes s using w.WriteString when available, avoiding the []byte(s) copy.

io.LimitReader(r, n)

A reader that returns io.EOF after n bytes. Basis for bounding untrusted input.

io.MultiReader(r...)

Concatenates readers: reads each to EOF in turn.

io.MultiWriter(w...)

Duplicates writes to every writer; stops at the first error.

io.TeeReader(r, w)

Returns a reader that writes everything it reads from r to w (a w error stops the read).

io.NewSectionReader(ra, off, n)

A reader, seeker and ReaderAt over a window of ra. Outer (Go 1.22) returns the original reader and window.

io.NewOffsetWriter(wa, off)

A writer, seeker and WriterAt that writes at an offset within wa (Go 1.20).

io.Pipe()

A synchronous in-memory pipe: a connected PipeReader and PipeWriter.

io.NopCloser(r)

Wraps a Reader as a ReadCloser whose Close does nothing (it keeps WriterTo if r has it).

io.Discard

A Writer that accepts and drops everything; efficient with Copy.

The sentinel errors are io.EOF, io.ErrUnexpectedEOF, io.ErrShortWrite, io.ErrShortBuffer, io.ErrNoProgress and io.ErrClosedPipe.

package main

import (
	"bytes"
	"errors"
	"fmt"
	"io"
	"os"
	"strings"
)

func main() {
	src := strings.NewReader("978-0-13-419044-0 The Go Programming Language")

	// Copy streams until EOF and reports the byte count.
	n, err := io.Copy(os.Stdout, src)
	fmt.Printf("\nCopy: n=%d err=%v\n", n, err)

	// CopyN stops after n bytes; a short source gives io.EOF.
	src.Reset("shelf")
	var sink bytes.Buffer
	n, err = io.CopyN(&sink, src, 10)
	fmt.Printf("CopyN: n=%d err=%v got=%q\n", n, err, sink.String())

	// ReadFull needs the buffer to be filled completely.
	src.Reset("978-0")
	buf := make([]byte, 8)
	n2, err := io.ReadFull(src, buf)
	fmt.Printf("ReadFull: n=%d err=%v unexpected=%t\n", n2, err, errors.Is(err, io.ErrUnexpectedEOF))

	src.Reset("")
	n2, err = io.ReadFull(src, buf)
	fmt.Printf("ReadFull on empty input: n=%d err=%v eof=%t\n", n2, err, err == io.EOF)

	// ReadAtLeast accepts a larger buffer but insists on a minimum.
	src.Reset("abcdef")
	n2, err = io.ReadAtLeast(src, buf, 4)
	fmt.Printf("ReadAtLeast: n=%d err=%v\n", n2, err)
	n2, err = io.ReadAtLeast(src, buf[:2], 4)
	fmt.Printf("ReadAtLeast with a short buffer: n=%d err=%v short=%t\n", n2, err, errors.Is(err, io.ErrShortBuffer))

	// ReadAll reads to EOF (a nil error means EOF was reached).
	all, err := io.ReadAll(io.LimitReader(strings.NewReader("0123456789"), 4))
	fmt.Printf("ReadAll(LimitReader 4): %q err=%v\n", all, err)

	// WriteString uses a WriteString method when the writer has one.
	n2, err = io.WriteString(&sink, "!")
	fmt.Printf("WriteString: n=%d err=%v now=%q\n", n2, err, sink.String())
}
978-0-13-419044-0 The Go Programming Language
Copy: n=45 err=<nil>
CopyN: n=5 err=EOF got="shelf"
ReadFull: n=5 err=unexpected EOF unexpected=true
ReadFull on empty input: n=0 err=EOF eof=true
ReadAtLeast: n=6 err=<nil>
ReadAtLeast with a short buffer: n=0 err=short buffer short=true
ReadAll(LimitReader 4): "0123" err=<nil>
WriteString: n=1 err=<nil> now="shelf!"

Note the difference between the two ReadFull calls: an empty source gives plain io.EOF (nothing was read, so there is no partial structure) and a short one gives io.ErrUnexpectedEOF. Code that parses a fixed-size header should treat io.EOF as a clean end of the stream and io.ErrUnexpectedEOF as corruption.

package main

import (
	"fmt"
	"io"
	"os"
	"strings"
)

type countingWriter struct{ n int }

func (c *countingWriter) Write(p []byte) (int, error) {
	c.n += len(p)
	return len(p), nil
}

func main() {
	// MultiReader: logical concatenation of readers.
	header := strings.NewReader("isbn,title\n")
	body := strings.NewReader("978-0-13-419044-0,The Go Programming Language\n")
	io.Copy(os.Stdout, io.MultiReader(header, body))

	// MultiWriter: duplicate output (here: stdout and a counter).
	var c countingWriter
	mw := io.MultiWriter(os.Stdout, &c)
	fmt.Fprintln(mw, "written once, delivered twice")
	fmt.Println("counter saw", c.n, "bytes")

	// TeeReader: whatever is read from r is also written to w.
	var seen countingWriter
	tee := io.TeeReader(strings.NewReader("hello, shelf"), &seen)
	b, _ := io.ReadAll(tee)
	fmt.Printf("read %q, tee saw %d bytes\n", b, seen.n)

	// SectionReader: a window onto an io.ReaderAt.
	data := strings.NewReader("0123456789abcdef")
	sec := io.NewSectionReader(data, 4, 6)
	part, _ := io.ReadAll(sec)
	fmt.Printf("section %q size=%d\n", part, sec.Size())
	sec.Seek(2, io.SeekStart)
	part, _ = io.ReadAll(sec)
	fmt.Printf("after Seek(2): %q\n", part)
	_, off, n := sec.Outer()
	fmt.Println("Outer window:", off, n)

	// NopCloser and Discard.
	rc := io.NopCloser(strings.NewReader("x"))
	fmt.Println("NopCloser Close:", rc.Close())
	k, _ := io.Copy(io.Discard, strings.NewReader("thrown away"))
	fmt.Println("Discard swallowed", k, "bytes")

	// OffsetWriter writes through an io.WriterAt at a fixed offset.
	f, _ := os.CreateTemp("", "offset-*")
	defer os.Remove(f.Name())
	defer f.Close()
	f.WriteString("..........")
	ow := io.NewOffsetWriter(f, 3)
	ow.Write([]byte("ABC"))
	got, _ := os.ReadFile(f.Name())
	fmt.Printf("%s\n", got)
}
isbn,title
978-0-13-419044-0,The Go Programming Language
written once, delivered twice
counter saw 30 bytes
read "hello, shelf", tee saw 12 bytes
section "456789" size=6
after Seek(2): "6789"
Outer window: 4 6
NopCloser Close: <nil>
Discard swallowed 11 bytes
...ABC....

A few uses worth remembering. io.MultiWriter(os.Stdout, logFile) sends output to the terminal and a file. io.TeeReader(resp.Body, hash) computes a checksum while a download streams to its destination, in one pass and without holding the body in memory. io.NewSectionReader(file, off, n) lets a parser read a region of a file (one entry of a container format, say) as if it were a file of its own, and many goroutines can each have their own section of the same *os.File because the underlying ReadAt calls are independent.

How io.Copy chooses its path

io.Copy is the workhorse, and its choice of strategy matters for performance. If the source implements io.WriterTo it calls src.WriteTo(dst); otherwise, if the destination implements io.ReaderFrom it calls dst.ReadFrom(src); otherwise it allocates a 32 KiB buffer and loops. (LimitedReader sources get a buffer no larger than their limit.) The fast paths exist because a type can often do better than a generic loop: *os.File asks the operating system to move the data without copying it through user space (sendfile, copy_file_range or splice on Linux, depending on the kinds of source and destination), and *bytes.Buffer copies straight from its internal slice. The consequence for wrappers is that a wrapper struct that embeds a Reader hides these fast paths, and a wrapper that must see every byte (a counting or hashing writer) must sit where Copy cannot bypass it.

package main

import (
	"fmt"
	"io"
	"strings"
)

// loud reports which interface io.Copy picked.
type loud struct{ *strings.Reader }

func (l loud) WriteTo(w io.Writer) (int64, error) {
	fmt.Println("  io.Copy used WriterTo")
	return l.Reader.WriteTo(w)
}

type sink struct{ n int }

func (s *sink) Write(p []byte) (int, error) { s.n += len(p); return len(p), nil }

type sinkFrom struct{ sink }

func (s *sinkFrom) ReadFrom(r io.Reader) (int64, error) {
	fmt.Println("  io.Copy used ReaderFrom")
	n, err := io.Copy(&s.sink, struct{ io.Reader }{r}) // hide the interfaces to avoid recursion
	return n, err
}

func main() {
	fmt.Println("source has WriterTo:")
	io.Copy(&sink{}, loud{strings.NewReader("abc")})

	fmt.Println("both ends: the source's WriteTo wins over the destination's ReadFrom:")
	io.Copy(&sinkFrom{}, loud{strings.NewReader("abc")})

	fmt.Println("destination has ReadFrom, source hidden:")
	io.Copy(&sinkFrom{}, struct{ io.Reader }{strings.NewReader("abc")})

	fmt.Println("both hidden behind plain wrappers (uses a 32 KiB buffer loop):")
	n, _ := io.Copy(&sink{}, struct{ io.Reader }{strings.NewReader("abc")})
	fmt.Println("  copied", n)

	fmt.Println("CopyBuffer with a small custom buffer:")
	buf := make([]byte, 2)
	n, err := io.CopyBuffer(&sink{}, struct{ io.Reader }{strings.NewReader("abcdef")}, buf)
	fmt.Println("  copied", n, err)
}
source has WriterTo:
  io.Copy used WriterTo
both ends: the source's WriteTo wins over the destination's ReadFrom:
  io.Copy used WriterTo
destination has ReadFrom, source hidden:
  io.Copy used ReaderFrom
both hidden behind plain wrappers (uses a 32 KiB buffer loop):
  copied 3
CopyBuffer with a small custom buffer:
  copied 6 <nil>

Wrapping a reader as struct{ io.Reader } is the standard idiom for hiding optional interfaces, and the os package itself uses the same trick (an embedded noReadFrom type) to avoid recursion in its own ReadFrom.

io.Pipe: connecting a writer to a reader

io.Pipe returns a connected pair. A Write on the PipeWriter blocks until one or more Read calls on the PipeReader have consumed all of its bytes; the pipe has no internal buffer, so it is a rendezvous between two goroutines. Its use case is adapting an API that writes to one that reads: json.NewEncoder(pw).Encode(v) in a goroutine, http.Post(url, "application/json", pr) in the caller streams a JSON request body without building it in memory.

package main

import (
	"errors"
	"fmt"
	"io"
)

func main() {
	// The writer goroutine produces; the reader consumes. There is no buffer:
	// every Write blocks until the reads have consumed all of its bytes.
	pr, pw := io.Pipe()
	go func() {
		for _, title := range []string{"Go in Action", "Concurrency in Go", "Learning Go"} {
			fmt.Fprintln(pw, title)
		}
		pw.Close() // the reader now sees io.EOF
	}()
	b, err := io.ReadAll(pr)
	fmt.Printf("err=%v\n%s", err, b)

	// CloseWithError delivers a custom error to the reader.
	pr2, pw2 := io.Pipe()
	boom := errors.New("export aborted")
	go func() {
		pw2.Write([]byte("partial"))
		pw2.CloseWithError(boom)
	}()
	b, err = io.ReadAll(pr2)
	fmt.Printf("got %q, err=%v, is boom: %t\n", b, err, errors.Is(err, boom))

	// If the reader goes away, the writer gets io.ErrClosedPipe (or the reader's error).
	pr3, pw3 := io.Pipe()
	pr3.Close()
	_, err = pw3.Write([]byte("nobody listens"))
	fmt.Println("write after reader closed:", err, errors.Is(err, io.ErrClosedPipe))
}
err=<nil>
Go in Action
Concurrency in Go
Learning Go
got "partial", err=export aborted, is boom: true
write after reader closed: io: read/write on closed pipe true

Source: io.Pipe

Three rules keep pipes from leaking goroutines. The writer end must always be closed (Close or CloseWithError), or the reader never sees EOF; CloseWithError is how the producer reports failure to the consumer. The reader end must be closed when the consumer stops early, or the producer blocks forever in Write; closing it makes the producer’s Write return io.ErrClosedPipe. And Read and Write calls on a pipe are safe for parallel use, but the data of a parallel Write is not interleaved with another’s. See Concurrency Patterns for the goroutine-lifetime discipline that applies, and Context for tying the producer to a request.

bytes and strings: in-memory readers and writers

Three types turn memory into the interfaces above, and they are the usual test doubles for files.

  • *bytes.Buffer is a growable byte buffer that is both a Reader and a Writer (and a ByteScanner, RuneScanner, ReaderFrom, WriterTo and StringWriter). The zero value is ready to use. Besides Write* and Read* it has Next(n), Truncate(n), Reset(), Grow(n), Len(), Available() and AvailableBuffer() (Go 1.21, for appending into the buffer’s spare capacity), and Peek(n) (Go 1.26), which returns the next bytes without consuming them. Bytes() and String() return the unread portion; Bytes() aliases the buffer’s memory.

  • *strings.Builder is a write-only buffer for building a string without copying: String() returns the accumulated bytes without allocating. It must not be copied after first use (a copy panics on write), and Grow preallocates.

  • *strings.Reader and *bytes.Reader are read-only, seekable views of a string or a byte slice. They implement Reader, ReaderAt, Seeker, WriterTo, ByteScanner and RuneScanner; Reset re-points them at new data, and Size returns the original length.

package main

import (
	"bytes"
	"fmt"
	"io"
	"strings"
)

func main() {
	// bytes.Buffer is a Reader and a Writer; the zero value is ready to use.
	var buf bytes.Buffer
	buf.WriteString("978-0-13-419044-0;")
	buf.WriteByte('X')
	fmt.Fprintf(&buf, "|%03d", 7)
	fmt.Println("len:", buf.Len(), "content:", buf.String())

	// Peek (Go 1.26) looks at the next bytes without consuming them.
	p, err := buf.Peek(3)
	fmt.Printf("Peek: %q %v, Len still %d\n", p, err, buf.Len())

	// Next consumes; ReadString reads through a delimiter.
	fmt.Printf("Next(3): %q\n", buf.Next(3))
	s, err := buf.ReadString(';')
	fmt.Printf("ReadString: %q %v\n", s, err)
	rest, _ := io.ReadAll(&buf)
	fmt.Printf("rest: %q, Len %d\n", rest, buf.Len())

	// strings.Builder only writes; String() does not copy.
	var sb strings.Builder
	for i := range 3 {
		fmt.Fprintf(&sb, "[%d]", i)
	}
	fmt.Println("Builder:", sb.String(), sb.Len())

	// strings.Reader is a read-only, seekable view of a string.
	r := strings.NewReader("catalogue")
	r.Seek(-4, io.SeekEnd)
	tail, _ := io.ReadAll(r)
	fmt.Printf("tail %q, size %d\n", tail, r.Size())
}
len: 23 content: 978-0-13-419044-0;X|007
Peek: "978" <nil>, Len still 23
Next(3): "978"
ReadString: "-0-13-419044-0;" <nil>
rest: "X|007", Len 0
Builder: [0][1][2] 9
tail "ogue", size 9

Prefer strings.Builder when the end product is a string and bytes.Buffer when the data is bytes, or must be read as well as written. Neither is safe for concurrent use. The string-processing functions that sit around these types (strings.Split, strings.Fields, the Lines and SplitSeq iterators added in Go 1.24) are covered in fmt, Strings and Text Processing.

Buffered I/O with bufio

Every Read or Write on a file is a system call. A program that reads a text file a byte at a time, or writes a line at a time with fmt.Fprintln(f, …), spends most of its time crossing into the kernel. Package bufio wraps a reader or writer in a memory buffer (4096 bytes by default) so that the underlying object sees few, large calls.

bufio.Reader

bufio.NewReader(r) returns a *bufio.Reader with a 4096-byte buffer; bufio.NewReaderSize(r, n) picks the size (the minimum is 16 bytes). If r is already a *bufio.Reader with a large enough buffer, NewReader returns it unchanged. The reader implements io.Reader, io.ByteScanner, io.RuneScanner and io.WriterTo, and adds the line and token methods:

Method Behaviour

Read(p)

Reads into p; at most one underlying Read, so it can return fewer bytes than len(p).

ReadByte, UnreadByte, ReadRune, UnreadRune

Single byte or UTF-8 rune; each Unread undoes only the most recent read.

Peek(n)

The next n bytes without consuming them (valid until the next read). Fails with bufio.ErrBufferFull if n exceeds the buffer size.

Discard(n)

Skips n bytes.

ReadString(delim), ReadBytes(delim)

Read up to and including the delimiter byte, growing as needed; at EOF without a delimiter they return the data read and io.EOF. No length limit.

ReadSlice(delim)

Returns a slice into the buffer, valid until the next read. Fails with ErrBufferFull if the delimiter is not within the buffer. Low-level.

ReadLine()

Returns a line without the end-of-line bytes and an isPrefix flag set when the line did not fit. Low-level; most code should use Scanner or ReadString('\n') (the documentation says so).

Buffered(), Size(), Reset(r)

Number of buffered bytes, buffer size, reuse with a new source.

WriteTo(w)

Writes the remaining data to w; used by io.Copy.

package main

import (
	"bufio"
	"errors"
	"fmt"
	"io"
	"strings"
)

func main() {
	in := "978-0-13-419044-0|The Go Programming Language|Donovan\nlast line without newline"
	r := bufio.NewReaderSize(strings.NewReader(in), 16) // 16 is the minimum buffer size

	// Peek looks ahead without consuming.
	head, err := r.Peek(5)
	fmt.Printf("Peek: %q %v\n", head, err)

	// ReadString consumes up to and including the delimiter.
	isbn, err := r.ReadString('|')
	fmt.Printf("ReadString: %q %v\n", isbn, err)

	// ReadRune decodes UTF-8; UnreadRune undoes exactly one.
	ru, size, _ := r.ReadRune()
	fmt.Printf("ReadRune: %q size=%d\n", ru, size)
	r.UnreadRune()

	// ReadSlice returns a view into the buffer and fails when the delimiter is further away than the buffer.
	_, err = r.ReadSlice('\n')
	fmt.Println("ReadSlice with a 16-byte buffer:", err, errors.Is(err, bufio.ErrBufferFull))

	// ReadLine reports isPrefix when the line is longer than the buffer.
	r = bufio.NewReaderSize(strings.NewReader("abcdefghijklmnopqrstuvwxyz\nshort\n"), 16)
	for {
		line, isPrefix, err := r.ReadLine()
		if err != nil {
			fmt.Println("ReadLine:", err)
			break
		}
		fmt.Printf("ReadLine: %q prefix=%t\n", line, isPrefix)
	}

	// ReadString has no such limit and returns the data together with io.EOF.
	r = bufio.NewReader(strings.NewReader(in))
	for {
		line, err := r.ReadString('\n')
		fmt.Printf("line %q err=%v\n", line, err)
		if err == io.EOF {
			break
		}
	}
	fmt.Println("buffered now:", r.Buffered())
}
Peek: "978-0" <nil>
ReadString: "978-0-13-419044-0|" <nil>
ReadRune: 'T' size=1
ReadSlice with a 16-byte buffer: bufio: buffer full true
ReadLine: "abcdefghijklmnop" prefix=true
ReadLine: "qrstuvwxyz" prefix=false
ReadLine: "short" prefix=false
ReadLine: EOF
line "978-0-13-419044-0|The Go Programming Language|Donovan\n" err=<nil>
line "last line without newline" err=EOF
buffered now: 0

Source: bufio.Reader

The last loop is the standard pattern for reading lines with ReadString: the final line without a trailing newline is returned together with io.EOF, so the code must use the data before testing the error. That is the same rule as for Read, one level up.

bufio.Writer

bufio.NewWriter(w) returns a *bufio.Writer with a 4096-byte buffer (NewWriterSize picks the size). Data is passed on to w when the buffer fills, or when Flush is called. Two properties define how it must be used:

  • Flush is not optional. Whatever remains in the buffer when the program (or the function that owns the writer) ends is lost unless Flush is called, and there is no finalizer to save you. The usual shape is defer w.Flush() for a writer to something unimportant, and an explicit if err := w.Flush(); err != nil { return err } for anything where a failed write matters.

  • Errors are sticky. After the first write error, every later call on the writer returns that error and writes nothing more; Flush returns it too. So the code can write many items without checking each one, and check Flush once, provided the destination (not just the buffer) matters.

package main

import (
	"bufio"
	"bytes"
	"errors"
	"fmt"
)

type failingWriter struct{ left int }

var errDiskFull = errors.New("disk full")

func (f *failingWriter) Write(p []byte) (int, error) {
	if len(p) > f.left {
		n := f.left
		f.left = 0
		return n, errDiskFull
	}
	f.left -= len(p)
	return len(p), nil
}

func main() {
	var dst bytes.Buffer
	w := bufio.NewWriterSize(&dst, 32)

	w.WriteString("ISBN,Title\n")
	fmt.Printf("after write: buffered=%d available=%d destination=%q\n", w.Buffered(), w.Available(), dst.String())

	w.Flush()
	fmt.Printf("after Flush: buffered=%d destination=%q\n", w.Buffered(), dst.String())

	// Writes larger than the free space flush to the destination on their own.
	w.WriteString("978-0-13-419044-0,The Go Programming Language\n")
	fmt.Printf("big write: buffered=%d destination bytes=%d\n", w.Buffered(), dst.Len())

	// Forgetting Flush loses the tail.
	w.WriteString("tail")
	fmt.Printf("never flushed: %d bytes still in the buffer\n", w.Buffered())

	// Errors are sticky: after the first failure every call returns it, and Flush reports it.
	fw := &failingWriter{left: 10}
	bw := bufio.NewWriterSize(fw, 16)
	for i := range 4 {
		_, err := bw.WriteString("0123456789")
		fmt.Printf("write %d: err=%v\n", i, err)
	}
	fmt.Println("Flush:", bw.Flush())
}
after write: buffered=11 available=21 destination=""
after Flush: buffered=0 destination="ISBN,Title\n"
big write: buffered=0 destination bytes=57
never flushed: 4 bytes still in the buffer
write 0: err=<nil>
write 1: err=disk full
write 2: err=disk full
write 3: err=disk full
Flush: disk full

Source: bufio.Writer

In the failing case the first write (10 bytes) fits the 16-byte buffer, the second overflows it, the destination accepts only what remains of its quota, and from then on the writer reports disk full. Available, Buffered, AvailableBuffer (append-style writes straight into the buffer, for use with strconv.AppendInt and friends) and ReadFrom complete the API. A bufio.Writer is not safe for concurrent use. bufio.NewReadWriter(r, w) pairs a reader and a writer for protocols that use both.

Do not wrap something that already buffers: bytes.Buffer, strings.Builder and a net/http response body gain nothing, and fmt.Fprintf on an http.ResponseWriter is already buffered by the server. Do wrap files and network connections when the access pattern is many small reads or writes. And do not wrap a reader twice: a json.Decoder has its own buffer, so the bufio.NewReader in the chain example later on this page is there to show the shape of the chain and is not needed for speed.

bufio.Scanner

bufio.Scanner is the idiomatic way to read a stream of lines or words. Scan() advances to the next token and reports whether there is one; Text() returns it as a string and Bytes() as a slice that the next Scan may overwrite; Err() returns the first non-EOF error after Scan returns false.

package main

import (
	"bufio"
	"fmt"
	"strings"
)

func main() {
	text := "The Go Programming Language\nLearning Go\r\n\nConcurrency in Go"

	// ScanLines (the default) strips \n and an optional \r; empty lines are tokens.
	sc := bufio.NewScanner(strings.NewReader(text))
	for sc.Scan() {
		fmt.Printf("line %q\n", sc.Text())
	}
	if err := sc.Err(); err != nil {
		fmt.Println("error:", err)
	}

	// ScanWords splits on Unicode white space.
	sc = bufio.NewScanner(strings.NewReader(text))
	sc.Split(bufio.ScanWords)
	words := 0
	for sc.Scan() {
		words++
	}
	fmt.Println("words:", words)

	// ScanRunes yields one UTF-8 sequence per token; ScanBytes one byte.
	sc = bufio.NewScanner(strings.NewReader("Ünï"))
	sc.Split(bufio.ScanRunes)
	for sc.Scan() {
		fmt.Printf("%q ", sc.Text())
	}
	fmt.Println()

	// Bytes returns a slice that the next Scan overwrites; copy it if you keep it.
	sc = bufio.NewScanner(strings.NewReader("ab\ncd\n"))
	var kept [][]byte
	var cloned [][]byte
	for sc.Scan() {
		kept = append(kept, sc.Bytes())
		cloned = append(cloned, append([]byte(nil), sc.Bytes()...))
	}
	fmt.Printf("kept without copying: %q, cloned: %q\n", kept, cloned)
}
line "The Go Programming Language"
line "Learning Go"
line ""
line "Concurrency in Go"
words: 9
"Ü" "n" "ï"
kept without copying: ["ab" "cd"], cloned: ["ab" "cd"]

Source: bufio.Scanner

The default split function is bufio.ScanLines: it strips a trailing \n and a preceding \r, so Windows line endings come out clean, and it treats a final line without a newline as a token. The other three are ScanWords (runs of non-space), ScanRunes (one UTF-8 sequence, with an invalid byte turned into the three-byte replacement character U+FFFD) and ScanBytes. Select one with sc.Split(fn) before the first Scan.

Always check sc.Err() after the loop. A for sc.Scan() loop ends for two reasons, EOF and failure, and only Err tells them apart. A scan that silently stops at a too-long line looks like a short file. Go 1.27’s go vet has no check for the omission; a scannererr analyzer is on the list for Go 1.28, which was not released on the day this page was checked, so it is an upcoming feature and not something to rely on yet.

Long lines

The scanner holds a whole token in its buffer, and the buffer may grow to bufio.MaxScanTokenSize, 64 KiB, by default. A longer line makes Scan return false with bufio.ErrTooLong. Call sc.Buffer(initial, max) before the first Scan to raise the limit, or read with bufio.Reader.ReadString/ReadBytes, which have no limit (and will happily read a gigabyte-long "line" into memory, which is the reason the scanner has one).

package main

import (
	"bufio"
	"errors"
	"fmt"
	"strings"
)

func main() {
	long := strings.Repeat("x", bufio.MaxScanTokenSize) + "\nnext\n"

	// The default limit is bufio.MaxScanTokenSize (64 KiB): a longer line stops the scan.
	sc := bufio.NewScanner(strings.NewReader(long))
	lines := 0
	for sc.Scan() {
		lines++
	}
	fmt.Printf("default: lines=%d err=%v too long=%t\n", lines, sc.Err(), errors.Is(sc.Err(), bufio.ErrTooLong))

	// Buffer raises the limit (call it before the first Scan).
	sc = bufio.NewScanner(strings.NewReader(long))
	sc.Buffer(make([]byte, 0, 4096), 1<<20)
	lines = 0
	for sc.Scan() {
		lines++
	}
	fmt.Printf("1 MiB limit: lines=%d err=%v\n", lines, sc.Err())

	// bufio.Reader.ReadString has no token limit; the whole line is held in memory.
	r := bufio.NewReader(strings.NewReader(long))
	line, err := r.ReadString('\n')
	fmt.Printf("ReadString: %d bytes err=%v\n", len(line), err)
}
default: lines=0 err=bufio.Scanner: token too long too long=true
1 MiB limit: lines=2 err=<nil>
ReadString: 65537 bytes err=<nil>

Custom split functions

A bufio.SplitFunc has the signature func(data []byte, atEOF bool) (advance int, token []byte, err error). The scanner calls it with the buffered data; it returns how many bytes to consume, the token (or nil for "need more data") and an error. Returning bufio.ErrFinalToken with a token delivers it as the last one and stops without error.

package main

import (
	"bufio"
	"bytes"
	"fmt"
	"strings"
)

// scanFields splits on ';' and trims spaces. It follows the bufio.SplitFunc contract.
func scanFields(data []byte, atEOF bool) (advance int, token []byte, err error) {
	if atEOF && len(data) == 0 {
		return 0, nil, nil // no more tokens
	}
	if i := bytes.IndexByte(data, ';'); i >= 0 {
		return i + 1, bytes.TrimSpace(data[:i]), nil // token found; skip the delimiter
	}
	if atEOF {
		return len(data), bytes.TrimSpace(data), nil // final token without delimiter
	}
	return 0, nil, nil // ask for more data
}

func main() {
	sc := bufio.NewScanner(strings.NewReader("978-0-13-419044-0 ; The Go Programming Language;  Donovan ;"))
	sc.Split(scanFields)
	for sc.Scan() {
		fmt.Printf("%q\n", sc.Text())
	}
	fmt.Println("err:", sc.Err())
}
"978-0-13-419044-0"
"The Go Programming Language"
"Donovan"
err: <nil>

Source: bufio.SplitFunc

The three return shapes are the whole contract: (n, token, nil) for a token found, (0, nil, nil) for "give me more bytes" (the scanner reads more, or calls again with atEOF set) and (0, nil, err) for failure. At EOF with data left over the function is called one last time and must decide what the remainder is.

package main

import (
	"bufio"
	"fmt"
	"strings"
)

// try runs f and prints the panic value, if any.
func try(name string, f func()) {
	defer func() { fmt.Printf("%s: panic: %v\n", name, recover()) }()
	f()
}

func main() {
	try("Buffer after Scan", func() {
		sc := bufio.NewScanner(strings.NewReader("a\nb\n"))
		sc.Scan()
		sc.Buffer(nil, 10)
	})
	try("Split after Scan", func() {
		sc := bufio.NewScanner(strings.NewReader("a\nb\n"))
		sc.Scan()
		sc.Split(bufio.ScanWords)
	})
	// Bad advance counts are reported through Err, not by panicking.
	for _, adv := range []int{-1, 100} {
		sc := bufio.NewScanner(strings.NewReader("abc"))
		sc.Split(func(data []byte, atEOF bool) (int, []byte, error) {
			return adv, nil, nil
		})
		ok := sc.Scan()
		fmt.Printf("advance %d: Scan=%t err=%v\n", adv, ok, sc.Err())
	}

	// A split function that returns an empty token without advancing does not
	// panic while the input is healthy: Scan simply keeps returning true.
	sc := bufio.NewScanner(strings.NewReader("abc"))
	sc.Split(func(data []byte, atEOF bool) (int, []byte, error) {
		return 0, []byte{}, nil
	})
	for i := 0; i < 3 && sc.Scan(); i++ {
		fmt.Printf("token %d: %q (no progress)\n", i, sc.Text())
	}
}
Buffer after Scan: panic: Buffer called after Scan
Split after Scan: panic: Split called after Scan
advance -1: Scan=false err=bufio.Scanner: SplitFunc returns negative advance count
advance 100: Scan=false err=bufio.Scanner: SplitFunc returns advance count beyond input
token 0: "" (no progress)
token 1: "" (no progress)
token 2: "" (no progress)

Buffer and Split panic when called after Scan has started, and a split function that returns a negative or too large advance makes Scan stop with bufio.ErrNegativeAdvance or bufio.ErrAdvanceTooFar. A split function that returns an empty token with no progress is not caught while the input is healthy (the loop above is cut short by hand); the scanner panics with bufio.Scan: too many empty tokens without progressing only when the reader has also failed, so a buggy split function is more likely to hang a program than to crash it.

Lines as an iterator

A scanner loop wraps naturally into a function that returns an iterator, which gives callers range syntax and automatic cleanup. The iterator yields the error as its last element; see Iterators and Range-over-Func for the iter.Seq2 pattern.

package main

import (
	"bufio"
	"fmt"
	"io"
	"iter"
	"strings"
)

// Lines yields the lines of r and, as its last element if any, the read error.
func Lines(r io.Reader) iter.Seq2[string, error] {
	return func(yield func(string, error) bool) {
		sc := bufio.NewScanner(r)
		sc.Buffer(nil, 1<<20)
		for sc.Scan() {
			if !yield(sc.Text(), nil) {
				return
			}
		}
		if err := sc.Err(); err != nil {
			yield("", err)
		}
	}
}

func main() {
	in := "978-0-13-419044-0\n978-1-49-207721-3\n\n978-0-596-00-1\n"
	for line, err := range Lines(strings.NewReader(in)) {
		if err != nil {
			fmt.Println("error:", err)
			break
		}
		if line == "" {
			continue
		}
		fmt.Println("isbn:", line)
	}

	// Stopping early is safe: the iterator returns when yield reports false.
	for line := range Lines(strings.NewReader(in)) {
		fmt.Println("first:", line)
		break
	}

	// For text that is already in memory, strings.Lines (Go 1.24) avoids the Scanner.
	for l := range strings.Lines("a\nb\r\nc") {
		fmt.Printf("%q ", l)
	}
	fmt.Println()
}
isbn: 978-0-13-419044-0
isbn: 978-1-49-207721-3
isbn: 978-0-596-00-1
first: 978-0-13-419044-0
"a\n" "b\r\n" "c"

Source: iter and strings.Lines

strings.Lines and bytes.Lines (Go 1.24) range over the lines of a string or byte slice that is already in memory and keep the line terminators; they do not read from a reader.

Files and directories with os

Package os is the portable interface to the operating system’s files, directories, environment and processes. This section covers files and directories; processes (os/exec, signals) are in Command-Line Programs.

Opening, creating and the flags

Function Behaviour

os.Open(name)

Opens for reading (O_RDONLY). The returned file’s Write fails.

os.Create(name)

Opens for reading and writing with O_RDWR|O_CREATE|O_TRUNC and mode 0o666 (before the umask): creates or truncates.

os.OpenFile(name, flag, perm)

The general form. perm is used only when the call creates the file.

os.ReadFile(name)

Reads the whole file. It uses the file size as an allocation hint and returns an empty slice, not an error, for an empty file.

os.WriteFile(name, data, perm)

O_WRONLY|O_CREATE|O_TRUNC, writes, closes. A failure mid-way can leave a partly written file; it does not fsync, and an existing file keeps its permissions.

os.CreateTemp(dir, pattern)

A new file with a random name, mode 0o600, opened O_RDWR|O_EXCL. A * in the pattern is replaced by the random part. An empty dir means os.TempDir().

os.MkdirTemp(dir, pattern)

The same for directories (mode 0o700).

os.Mkdir, os.MkdirAll

One directory, or all missing ancestors (no error if it already exists).

os.Remove, os.RemoveAll

A file or empty directory, or a whole tree (no error if missing).

os.Rename(old, new)

Moves or renames. Atomic on POSIX file systems when both paths are on the same file system; replaces an existing destination; fails across file systems.

os.Stat, os.Lstat

fs.FileInfo; Lstat does not follow a final symbolic link.

os.ReadDir(name)

All entries as []fs.DirEntry, sorted by file name; cheaper than the old ioutil.ReadDir because it avoids a stat per entry.

os.Symlink, os.Readlink, os.Link

Create or read links.

os.Chmod, os.Chown, os.Chtimes, os.Truncate

Metadata and size by name.

os.SameFile(a, b)

Whether two FileInfo values describe the same file.

os.Getwd, os.Chdir

Current directory (process-wide, so avoid it in concurrent code).

The flag constants are the OS flags: exactly one of O_RDONLY, O_WRONLY or O_RDWR, then any of O_APPEND (writes go to the end), O_CREATE (create if missing), O_EXCL (with O_CREATE: fail if it exists, the building block of lock files and race-free creation), O_SYNC (every write reaches the device) and O_TRUNC (truncate an existing file on open).

Permissions

os.FileMode is fs.FileMode: the low nine bits are the Unix permission bits and the high bits flag the type (ModeDir, ModeSymlink, ModeNamedPipe, ModeSocket, ModeDevice, ModeCharDevice, ModeIrregular) and attributes (ModeAppend, ModeExclusive, ModeTemporary, ModeSetuid, ModeSetgid, ModeSticky). Write permissions in octal with the 0o prefix (0o644, not 644, which is decimal and a classic error). The final mode is perm &^ umask, so 0o666 becomes 0o644 under the usual 022 umask. Mode().Perm() keeps the permission bits, Mode().IsRegular() and IsDir() test the type, and Mode().Type() returns only the type bits (what DirEntry.Type() reports). On Windows only the read-only bit is meaningful and everything else is synthesised.

package main

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
)

func check(err error) {
	if err != nil {
		fmt.Println("error:", err)
		os.Exit(1)
	}
}

func main() {
	dir, err := os.MkdirTemp("", "shelf-*")
	check(err)
	defer os.RemoveAll(dir)
	check(os.Chdir(dir)) // keep the printed paths short and stable

	// WriteFile creates or truncates; the permission is filtered by the umask.
	check(os.WriteFile("books.csv", []byte("isbn,title\n"), 0o644))

	// OpenFile with O_APPEND adds to the end.
	f, err := os.OpenFile("books.csv", os.O_APPEND|os.O_WRONLY, 0)
	check(err)
	_, err = f.WriteString("978-0-13-419044-0,The Go Programming Language\n")
	check(err)
	check(f.Close())

	data, err := os.ReadFile("books.csv")
	check(err)
	fmt.Printf("%s", data)

	// Stat describes the file.
	info, err := os.Stat("books.csv")
	check(err)
	fmt.Println(info.Name(), info.Size(), info.Mode(), info.IsDir(), info.Mode().IsRegular())

	// MkdirAll creates every missing parent and succeeds if the directory exists.
	check(os.MkdirAll(filepath.Join("data", "2026", "10"), 0o755))
	check(os.WriteFile(filepath.Join("data", "2026", "loans.json"), []byte("[]"), 0o600))
	check(os.Rename("books.csv", filepath.Join("data", "books.csv")))

	// ReadDir returns entries sorted by file name, without calling stat on each.
	entries, err := os.ReadDir("data")
	check(err)
	for _, e := range entries {
		fmt.Println(e.Name(), e.IsDir(), e.Type())
	}

	// A missing file is reported as a *fs.PathError that matches fs.ErrNotExist.
	_, err = os.Open("data/missing.txt")
	var pe *fs.PathError
	fmt.Println(err)
	fmt.Println(errors.Is(err, fs.ErrNotExist), errors.As(err, &pe), pe.Op, pe.Path)

	// Remove deletes one file or empty directory; RemoveAll a whole tree.
	fmt.Println(os.Remove("data"))
	check(os.RemoveAll("data"))
	_, err = os.Stat("data")
	fmt.Println(errors.Is(err, fs.ErrNotExist))
}
isbn,title
978-0-13-419044-0,The Go Programming Language
books.csv 57 -rw-r--r-- false true
2026 true d---------
books.csv false ----------
open data/missing.txt: no such file or directory
true true open data/missing.txt
remove data: directory not empty
true

Source: os

The entry for the directory is printed as d--------- because DirEntry.Type() reports the type bits only; to see permissions use entry.Info() (one stat) or os.Stat. The error for the missing file is a *fs.PathError (fields Op, Path, Err) whose Unwrap returns syscall.ENOENT, and errors.Is(err, fs.ErrNotExist) matches it on every platform. The older os.IsNotExist(err) predates wrapping and does not look inside wrapped errors; prefer errors.Is.

Methods of os.File

A *os.File implements Read, Write, ReadAt, WriteAt, Seek, Close, ReadFrom, WriteTo, WriteString, ReadDir, and adds Name, Stat, Sync (flush to stable storage), Truncate, Chmod, Chown, Chdir, SyscallConn (raw access to the descriptor; prefer it to Fd, after which SetDeadline stops working), ReadDir(n) and Readdirnames(n) (directory order, in batches), and SetDeadline, SetReadDeadline, SetWriteDeadline for descriptors that support them. A nil *os.File is safe to call: methods return os.ErrInvalid. A file is closed by Close, and by a finalizer when the garbage collector finds it unreachable, which is a safety net and not a strategy: close files explicitly.

package main

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
	"strings"
)

func main() {
	dir, _ := os.MkdirTemp("", "shelf-*")
	defer os.RemoveAll(dir)
	show := func(what string, err error) {
		fmt.Printf("%-26s %s\n", what, strings.ReplaceAll(fmt.Sprint(err), dir, "$DIR"))
	}

	// Reading a directory as if it were a file.
	_, err := os.ReadFile(dir)
	show("ReadFile on a directory:", err)

	// Opening a directory for writing.
	_, err = os.OpenFile(dir, os.O_WRONLY, 0)
	show("OpenFile(O_WRONLY) dir:", err)

	// Exclusive creation.
	p := dir + "/a.txt"
	os.WriteFile(p, nil, 0o644)
	_, err = os.OpenFile(p, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
	show("O_EXCL on existing file:", err)
	fmt.Println("  errors.Is(fs.ErrExist):", errors.Is(err, fs.ErrExist))

	// Permission errors (skipped when running as root).
	os.Chmod(p, 0o000)
	_, err = os.Open(p)
	show("Open without permission:", err)
	fmt.Println("  errors.Is(fs.ErrPermission):", errors.Is(err, fs.ErrPermission), "as root:", os.Geteuid() == 0)
	os.Chmod(p, 0o644)

	// Removing a non-empty directory.
	os.Mkdir(dir+"/d", 0o755)
	os.WriteFile(dir+"/d/x", nil, 0o644)
	show("Remove non-empty dir:", os.Remove(dir+"/d"))

	// An empty file is not an error: ReadFile returns an empty slice.
	b, err := os.ReadFile(p)
	fmt.Printf("%-26s len=%d err=%v\n", "ReadFile on empty file:", len(b), err)

	// A *os.File that is nil.
	var f *os.File
	_, err = f.Write([]byte("x"))
	show("Write on nil *os.File:", err)
	fmt.Println("  errors.Is(os.ErrInvalid):", errors.Is(err, os.ErrInvalid))
}
ReadFile on a directory:   read $DIR: is a directory
OpenFile(O_WRONLY) dir:    open $DIR: is a directory
O_EXCL on existing file:   open $DIR/a.txt: file exists
  errors.Is(fs.ErrExist): true
Open without permission:   open $DIR/a.txt: permission denied
  errors.Is(fs.ErrPermission): true as root: false
Remove non-empty dir:      remove $DIR/d: directory not empty
ReadFile on empty file:    len=0 err=<nil>
Write on nil *os.File:     invalid argument
  errors.Is(os.ErrInvalid): true

The platform-independent error values are fs.ErrNotExist (os.ErrNotExist), fs.ErrExist, fs.ErrPermission, fs.ErrInvalid, fs.ErrClosed, plus os.ErrDeadlineExceeded and os.ErrNoDeadline for deadlines. Match on these with errors.Is, not on the text, which differs between operating systems.

package main

import (
	"errors"
	"fmt"
	"os"
	"time"
)

func main() {
	// Deadlines work on pipes and sockets (pollable descriptors)...
	r, w, _ := os.Pipe()
	defer r.Close()
	defer w.Close()

	r.SetReadDeadline(time.Now().Add(20 * time.Millisecond))
	buf := make([]byte, 8)
	_, err := r.Read(buf) // nobody writes, so the deadline fires
	fmt.Println("pipe read:", err, errors.Is(err, os.ErrDeadlineExceeded))

	r.SetReadDeadline(time.Time{}) // clear the deadline
	w.WriteString("ready")
	n, err := r.Read(buf)
	fmt.Printf("pipe read after write: %q %v\n", buf[:n], err)

	// ...but not on regular files.
	f, _ := os.CreateTemp("", "deadline-*")
	defer os.Remove(f.Name())
	defer f.Close()
	err = f.SetDeadline(time.Now().Add(time.Second))
	fmt.Println("regular file:", err, errors.Is(err, os.ErrNoDeadline))
}
pipe read: read |0: i/o timeout true
pipe read after write: "ready" <nil>
regular file: file type does not support deadline true

File reads on a local disk cannot be interrupted or given a timeout, which is why a context-aware reader can only check the context between Read calls:

package main

import (
	"context"
	"errors"
	"fmt"
	"io"
	"strings"
)

// ctxReader stops reading when ctx is done. It can only check between Read calls:
// a Read that is already blocked inside the kernel is not interrupted.
type ctxReader struct {
	ctx context.Context
	r   io.Reader
}

func (c ctxReader) Read(p []byte) (int, error) {
	if err := c.ctx.Err(); err != nil {
		return 0, err
	}
	return c.r.Read(p)
}

// cancelAfter cancels the context once n bytes have been read.
type cancelAfter struct {
	r      io.Reader
	n      int
	cancel context.CancelFunc
}

func (c *cancelAfter) Read(p []byte) (int, error) {
	k, err := c.r.Read(p)
	c.n -= k
	if c.n <= 0 {
		c.cancel()
	}
	return k, err
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	src := &cancelAfter{r: strings.NewReader(strings.Repeat("x", 100)), n: 10, cancel: cancel}

	buf := make([]byte, 4)
	r := ctxReader{ctx, src}
	total := 0
	for {
		n, err := r.Read(buf)
		total += n
		if err != nil {
			fmt.Println("stopped after", total, "bytes:", err, errors.Is(err, context.Canceled))
			return
		}
	}
}
stopped after 12 bytes: context canceled true

Source: context

The check runs before each Read, so cancellation is seen after the Read that was already in progress has returned; the program above stopped at 12 bytes although cancellation was requested at 10. A read blocked on a pipe, a socket or a terminal can be unblocked only by closing the file or setting a deadline.

Closing: errors on write paths

A deferred Close on a file opened for reading is fine; its error carries no information you could act on. On a file opened for writing it is different: Close is where a delayed write error (a full disk, a quota, a failed network file system) is finally reported, so ignoring it means a program that reports success after losing data. Join the close error into the function’s result instead, with a named result and errors.Join:

package main

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"
)

// writeLines writes lines to path. The deferred Close error is joined into the result,
// because for a file opened for writing Close is where a failed flush surfaces.
func writeLines(path string, lines []string) (err error) {
	f, err := os.Create(path)
	if err != nil {
		return err
	}
	defer func() {
		err = errors.Join(err, f.Close())
	}()
	_, err = f.WriteString(strings.Join(lines, "\n") + "\n")
	return err
}

func main() {
	dir, _ := os.MkdirTemp("", "shelf-*")
	defer os.RemoveAll(dir)

	p := filepath.Join(dir, "titles.txt")
	fmt.Println("first call:", writeLines(p, []string{"Learning Go", "Concurrency in Go"}))
	b, _ := os.ReadFile(p)
	fmt.Printf("%q\n", b)

	// A path in a directory that does not exist fails at Create.
	err := writeLines(filepath.Join(dir, "nope", "titles.txt"), nil)
	fmt.Println("missing directory:", strings.ReplaceAll(err.Error(), dir, "$DIR"))

	// Close twice: the second Close reports os.ErrClosed, which is why a deferred
	// Close on a read-only file can be left alone but a write path must not ignore it.
	f, _ := os.Open(p)
	fmt.Println("Close:", f.Close())
	err = f.Close()
	fmt.Println("second Close:", strings.ReplaceAll(err.Error(), dir, "$DIR"), errors.Is(err, os.ErrClosed))
	_, err = f.Write([]byte("x"))
	fmt.Println("Write after Close:", strings.ReplaceAll(err.Error(), dir, "$DIR"))
}
first call: <nil>
"Learning Go\nConcurrency in Go\n"
missing directory: open $DIR/nope/titles.txt: no such file or directory
Close: <nil>
second Close: close $DIR/titles.txt: file already closed true
Write after Close: write $DIR/titles.txt: file already closed

The same pattern appears in Error Handling. Where durability matters (a database file, a journal) Close is still not enough: f.Sync() asks the operating system to flush to the device, and renaming a new file over an old one makes the replacement atomic.

Atomic replacement

Writing a configuration or state file in place has a window in which a crash leaves it half-written. The robust sequence is to write a complete temporary file in the same directory (so the rename stays on one file system), Sync it, close it and rename it over the target:

package main

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
)

// writeFileAtomic replaces path so that readers see either the old or the new content, never a mixture.
func writeFileAtomic(path string, data []byte, perm os.FileMode) (err error) {
	dir := filepath.Dir(path)
	tmp, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp") // same directory: same filesystem
	if err != nil {
		return err
	}
	defer func() {
		if err != nil {
			tmp.Close()
			os.Remove(tmp.Name())
		}
	}()
	if _, err = tmp.Write(data); err != nil {
		return err
	}
	if err = tmp.Chmod(perm); err != nil {
		return err
	}
	if err = tmp.Sync(); err != nil { // flush to stable storage before the rename
		return err
	}
	if err = tmp.Close(); err != nil {
		return err
	}
	return os.Rename(tmp.Name(), path) // atomic on POSIX file systems
}

func main() {
	dir, _ := os.MkdirTemp("", "shelf-*")
	defer os.RemoveAll(dir)
	p := filepath.Join(dir, "state.json")

	fmt.Println(writeFileAtomic(p, []byte(`{"version":1}`), 0o600))
	fmt.Println(writeFileAtomic(p, []byte(`{"version":2}`), 0o600))

	b, _ := os.ReadFile(p)
	st, _ := os.Stat(p)
	fmt.Printf("%s %v\n", b, st.Mode())

	entries, _ := os.ReadDir(dir)
	fmt.Println("files left in the directory:", len(entries))

	// A failure leaves the old content and no temporary file behind.
	err := writeFileAtomic(filepath.Join(dir, "missing", "state.json"), nil, 0o600)
	fmt.Println("unwritable target:", errors.Is(err, os.ErrNotExist))
}
<nil>
<nil>
{"version":2} -rw-------
files left in the directory: 1
unwritable target: true

Source: os.CreateTemp and os.Rename

On Windows os.Rename replaces the destination with MoveFileEx semantics; it can fail while another process holds the destination open. For an even stronger guarantee the containing directory is also fsync`ed, which the standard library does not do for you. Third-party packages such as `google/renameio wrap this pattern, but the twenty lines above need no dependency.

Environment, arguments and standard streams

os.Getenv returns "" for both an unset and an empty variable; os.LookupEnv returns the value and a boolean and is the right call when the difference matters. Setenv, Unsetenv and Clearenv change the process environment, which is shared state: they affect every goroutine and every child process started afterwards, so set variables for a test with t.Setenv, which restores them. os.ExpandEnv and os.Expand substitute $VAR and ${VAR}; os.Environ returns KEY=value strings. os.Args holds the command line (Args[0] is the program name; the flag package and the frameworks on Command-Line Programs parse it). The directory helpers os.UserHomeDir, os.UserConfigDir, os.UserCacheDir, os.TempDir, os.Getwd, os.Executable and os.Hostname hide platform conventions: UserConfigDir is $XDG_CONFIG_HOME or $HOME/.config on Linux, $HOME/Library/Application Support on macOS and %AppData% on Windows.

os.Stdin, os.Stdout and os.Stderr are ordinary *os.File values for file descriptors 0, 1 and 2. They are unbuffered (wrap Stdout in a bufio.Writer for bulk output and flush before exit), and diagnostics belong on standard error so that standard output stays machine-readable. os.Stdin.Stat() with Mode()&os.ModeCharDevice != 0 is the dependency-free way to tell a terminal from a pipe or a file.

package main

import (
	"fmt"
	"os"
	"strings"
)

func main() {
	os.Setenv("SHELF_DB", "postgres://localhost/shelf")
	os.Unsetenv("SHELF_DEBUG")

	// Getenv cannot tell unset from empty; LookupEnv can.
	fmt.Printf("Getenv(SHELF_DEBUG) = %q\n", os.Getenv("SHELF_DEBUG"))
	v, ok := os.LookupEnv("SHELF_DEBUG")
	fmt.Printf("LookupEnv(SHELF_DEBUG) = %q, %t\n", v, ok)
	os.Setenv("SHELF_DEBUG", "")
	v, ok = os.LookupEnv("SHELF_DEBUG")
	fmt.Printf("after Setenv to empty: %q, %t\n", v, ok)

	// ExpandEnv and Expand substitute $VAR and ${VAR}.
	fmt.Println(os.ExpandEnv("db=${SHELF_DB} user=$SHELF_USER."))
	fmt.Println(os.Expand("$ISBN is ${TITLE}", func(name string) string {
		return map[string]string{"ISBN": "978-0-13-419044-0", "TITLE": "The Go Programming Language"}[name]
	}))

	// Environ returns "KEY=value" strings.
	n := 0
	for _, kv := range os.Environ() {
		if strings.HasPrefix(kv, "SHELF_") {
			n++
		}
	}
	fmt.Println("SHELF_ variables:", n)

	// Directories that depend on the platform and the environment.
	home, err := os.UserHomeDir()
	fmt.Println("UserHomeDir ok:", home != "" && err == nil)
	cfg, err := os.UserConfigDir()
	fmt.Println("UserConfigDir ok:", cfg != "" && err == nil)
	fmt.Println("TempDir ok:", os.TempDir() != "")
	wd, _ := os.Getwd()
	exe, _ := os.Executable()
	fmt.Println("Getwd and Executable ok:", wd != "" && exe != "")

	// Arguments: Args[0] is the program name.
	fmt.Println("len(os.Args):", len(os.Args))

	// Standard streams are *os.File values.
	fmt.Fprintln(os.Stderr, "this line goes to standard error")
	fi, _ := os.Stdin.Stat()
	fmt.Println("stdin is a terminal:", fi.Mode()&os.ModeCharDevice != 0)
}
Getenv(SHELF_DEBUG) = ""
LookupEnv(SHELF_DEBUG) = "", false
after Setenv to empty: "", true
db=postgres://localhost/shelf user=.
978-0-13-419044-0 is The Go Programming Language
SHELF_ variables: 2
UserHomeDir ok: true
UserConfigDir ok: true
TempDir ok: true
Getwd and Executable ok: true
len(os.Args): 1
this line goes to standard error
stdin is a terminal: false

(The line from standard error appears in the output because the program was run with both streams captured.)

os.Exit(code) ends the process immediately: deferred functions do not run and buffered output is not flushed. The reliable structure is a run(...) int function that does the work and returns the exit code, called as os.Exit(run(...)) from main and nothing else. Taking the streams as parameters makes run testable with bytes.Buffer values:

package main

import (
	"bufio"
	"fmt"
	"io"
	"os"
	"strings"
)

// run is the testable core: it takes the streams as parameters instead of using os.Stdin and os.Stdout.
func run(in io.Reader, out, errOut io.Writer, args []string) int {
	w := bufio.NewWriter(out)
	defer w.Flush()

	sc := bufio.NewScanner(in)
	n := 0
	for sc.Scan() {
		line := strings.TrimSpace(sc.Text())
		if line == "" {
			continue
		}
		n++
		fmt.Fprintf(w, "%d: %s\n", n, strings.ToUpper(line))
	}
	if err := sc.Err(); err != nil {
		fmt.Fprintln(errOut, "shelf: reading input:", err)
		return 1
	}
	if n == 0 {
		fmt.Fprintln(errOut, "shelf: no input")
		return 2
	}
	return 0
}

func main() {
	// Typical wiring: the process streams go in here, and the exit code comes out.
	code := run(strings.NewReader("learning go\n\nconcurrency in go\n"), os.Stdout, os.Stderr, os.Args[1:])
	fmt.Println("exit code would be", code)
	code = run(strings.NewReader("\n\n"), os.Stdout, os.Stdout, nil)
	fmt.Println("exit code would be", code)
	// In a real main: os.Exit(run(os.Stdin, os.Stdout, os.Stderr, os.Args[1:])) -- after deferred work is done.
}
1: LEARNING GO
2: CONCURRENCY IN GO
exit code would be 0
shelf: no input
exit code would be 2

Source: os.Exit and bufio

The streams-and-exit-codes conventions that agents and shell scripts depend on are described in Streams and exit codes on the CLI-for-agents pages.

Testing code that reads and writes

Because the interfaces are tiny, test doubles are cheap. testing/iotest supplies the adversarial readers (OneByteReader, HalfReader, DataErrReader, ErrReader, TimeoutReader, TruncateWriter, NewReadLogger) and iotest.TestReader checks a reader against the full set of Reader contracts. For file-system code use t.TempDir() for scratch directories (removed automatically) and testing/fstest for fs.FS values (see below).

// upper.go
// Package upper provides a reader that upper-cases ASCII text.
package upper

import "io"

type reader struct{ r io.Reader }

// NewReader returns a reader that upper-cases the ASCII letters read from r.
func NewReader(r io.Reader) io.Reader { return reader{r} }

func (u reader) Read(p []byte) (int, error) {
	n, err := u.r.Read(p)
	for i := range p[:n] {
		if c := p[i]; 'a' <= c && c <= 'z' {
			p[i] = c - 'a' + 'A'
		}
	}
	return n, err
}

Source: io.Reader

// upper_test.go
package upper_test

import (
	"errors"
	"io"
	"strings"
	"testing"
	"testing/iotest"

	"example.com/shelf/ex/upper"
)

func TestReaderContract(t *testing.T) {
	// TestReader reads the stream in many ways (Read, ReadAt, Seek...) and checks the content and the contract.
	if err := iotest.TestReader(upper.NewReader(strings.NewReader("LEARNING GO")), []byte("LEARNING GO")); err != nil {
		t.Fatal(err)
	}
}

func TestUpper(t *testing.T) {
	const in = "the go programming language"
	for name, wrap := range map[string]func(io.Reader) io.Reader{
		"plain":      func(r io.Reader) io.Reader { return r },
		"one byte":   iotest.OneByteReader,
		"data+EOF":   iotest.DataErrReader,
		"half reads": iotest.HalfReader,
	} {
		t.Run(name, func(t *testing.T) {
			got, err := io.ReadAll(upper.NewReader(wrap(strings.NewReader(in))))
			if err != nil || string(got) != strings.ToUpper(in) {
				t.Fatalf("got %q, %v", got, err)
			}
		})
	}
}

func TestReadError(t *testing.T) {
	boom := errors.New("boom")
	_, err := io.ReadAll(upper.NewReader(iotest.ErrReader(boom)))
	if !errors.Is(err, boom) {
		t.Fatalf("err = %v, want %v", err, boom)
	}
}
=== RUN   TestReaderContract
--- PASS: TestReaderContract (0.00s)
=== RUN   TestUpper
=== RUN   TestUpper/plain
=== RUN   TestUpper/one_byte
=== RUN   TestUpper/data+EOF
=== RUN   TestUpper/half_reads
--- PASS: TestUpper (0.00s)
    --- PASS: TestUpper/plain (0.00s)
    --- PASS: TestUpper/one_byte (0.00s)
    --- PASS: TestUpper/data+EOF (0.00s)
    --- PASS: TestUpper/half_reads (0.00s)
=== RUN   TestReadError
--- PASS: TestReadError (0.00s)
PASS
ok  	example.com/shelf/ex/upper	0.181s

Source: testing/iotest

(The elapsed times in go test output differ from run to run.)

path and path/filepath

Go has two path packages because there are two kinds of path. Package path handles slash-separated paths, the form used in URLs, in io/fs names, in embed patterns and in archive entries; it behaves identically on every platform. Package path/filepath handles operating-system paths: it uses os.PathSeparator (/ on Unix, \ on Windows), knows about volume names such as C: and UNC paths, and is what you pass to the os functions. The two share their function names.

Function Behaviour

Join(elem...)

Joins with the separator and Clean`s the result; empty elements are ignored. `Join("a", "../b") is b.

Clean(p)

Lexical normalisation: collapses repeated separators, removes ., resolves .. against the preceding element, turns "" into .; /../x becomes /x. It does not look at the file system, so it can change the meaning of a path that contains symbolic links.

Split(p), Base(p), Dir(p), Ext(p)

Directory and file parts, last element (. for ""), everything but the last element, and the extension including the dot (.gz for books.json.gz).

Abs(p), IsAbs(p), VolumeName(p)

Absolute form (prepends the working directory), and tests. path has IsAbs only.

Rel(base, target)

The relative path from base to target, lexically; an error if one is absolute and the other is not.

Match(pattern, name), Glob(pattern)

Shell patterns: * (any run of non-separators), ?, [a-z], [^x] and \ escapes. There is no **. Glob reads the file system and ignores I/O errors; it returns filepath.ErrBadPattern for a malformed pattern.

SplitList(p)

Splits a PATH-style list on os.PathListSeparator (: or ;).

ToSlash, FromSlash

Convert between the OS separator and /.

EvalSymlinks(p)

Resolves symbolic links and cleans the result; the path must exist.

IsLocal(p)

Lexical test for "stays inside the directory it is evaluated in" (below). Go 1.20.

Localize(p)

Converts a valid slash-separated io/fs path into an OS path or fails (below). Go 1.23.

WalkDir(root, fn), Walk(root, fn)

Walk a tree in lexical order (below).

package main

import (
	"fmt"
	"path"
	"path/filepath"
)

func main() {
	// path always uses forward slashes (URLs, io/fs names); filepath uses the OS separator.
	fmt.Println(path.Join("catalog", "..", "loans", "2026/10.json"))
	fmt.Println(filepath.Join("catalog", "..", "loans", "2026/10.json"))
	fmt.Println(path.Join("a", "", "b"), path.Join("", ""), path.Join("/", "x"))

	// Clean removes ., .., and repeated separators lexically (it never touches the disk).
	for _, p := range []string{"a//b/./c/..", "../../x", "/../x", "", "./"} {
		fmt.Printf("Clean(%q) = %q\n", p, filepath.Clean(p))
	}

	// Split, Base, Dir and Ext.
	dir, file := filepath.Split("/var/lib/shelf/books.json.gz")
	fmt.Println(dir, file)
	fmt.Println(filepath.Base("/var/lib/shelf/"), filepath.Dir("/var/lib/shelf/books.json.gz"), filepath.Ext("books.json.gz"), filepath.Base(""))

	// Rel computes a relative path lexically.
	rel, err := filepath.Rel("/data/shelf", "/data/shelf/2026/books.json")
	fmt.Println(rel, err)
	_, err = filepath.Rel("/data/shelf", "relative/path")
	fmt.Println(err)

	// Abs, IsAbs and the volume name (empty on Unix).
	fmt.Println(filepath.IsAbs("/x"), filepath.IsAbs("x"), filepath.VolumeName("/x") == "")

	// Match and Glob use shell patterns; * does not cross a separator.
	ok, _ := filepath.Match("*.json", "books.json")
	ok2, _ := filepath.Match("*.json", "2026/books.json")
	ok3, _ := filepath.Match("book[0-9].j?on", "book7.json")
	_, err = filepath.Match("[", "x")
	fmt.Println(ok, ok2, ok3, err)

	// SplitList splits a PATH-style list on the OS list separator (':' on Unix).
	fmt.Println(filepath.SplitList("/usr/bin:/bin"), string(filepath.ListSeparator), string(filepath.Separator))

	// ToSlash and FromSlash convert between the two worlds.
	fmt.Println(filepath.ToSlash(filepath.Join("a", "b")), filepath.FromSlash("a/b"))
}
loans/2026/10.json
loans/2026/10.json
a/b  /x
Clean("a//b/./c/..") = "a/b"
Clean("../../x") = "../../x"
Clean("/../x") = "/x"
Clean("") = "."
Clean("./") = "."
/var/lib/shelf/ books.json.gz
shelf /var/lib/shelf .gz .
2026/books.json <nil>
Rel: can't make relative/path relative to /data/shelf
true false true
true false true syntax error in pattern
[/usr/bin /bin] : /
a/b a/b

Source: path/filepath and path

Use path for URL paths and io/fs names (path.Join("static", "app.css") for an embed.FS) and path/filepath for anything that reaches the operating system. Mixing them is the classic portability bug: filepath.Join produces static\app.css on Windows, which is not a valid io/fs name, and path.Join produces a forward-slash path that the Windows API usually accepts but that makes filepath.Base and filepath.Dir behave unexpectedly on drive-letter paths. Never build a path with string concatenation and a literal / or \. Platform differences to remember (none of the Windows rows is run here):

Topic Unix and macOS Windows

Separator

/

\ (and / is accepted)

Volume

none

C:, \\server\share

Case

sensitive on Linux, usually insensitive on macOS

insensitive

Reserved names

none

NUL, COM1, CON, … rejected by IsLocal and os.Root

Permission bits

all nine

only read-only is meaningful

Line endings

\n

\r\n in text files (bufio.ScanLines strips both)

Symbolic links

created freely

creating one needs a privilege or Developer Mode

Walking a tree

filepath.WalkDir(root, fn) (Go 1.16) visits root and everything below it in lexical order, calling fn(path, d fs.DirEntry, err error) error. It does not follow symbolic links (it reports them, and does not descend into a link to a directory), and it avoids a stat per entry because the DirEntry already knows its type. The function controls the walk by its result: returning filepath.SkipDir from a directory skips that directory (from a file it skips the remaining files of the directory), filepath.SkipAll (Go 1.20) ends the walk successfully, and any other error ends it with that error. fn is also called with a non-nil err when a directory cannot be read; returning nil then carries on without the directory’s contents. The older filepath.Walk does the same with an os.FileInfo per entry and a stat for every file; prefer WalkDir.

package main

import (
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
)

func main() {
	dir, _ := os.MkdirTemp("", "shelf-*")
	defer os.RemoveAll(dir)
	os.Chdir(dir)
	for _, p := range []string{"a/one.json", "a/two.txt", "b/three.json", ".git/config", "b/deep/four.json"} {
		os.MkdirAll(filepath.Dir(p), 0o755)
		os.WriteFile(p, nil, 0o644)
	}
	os.Symlink("a", "link-to-a")

	// WalkDir visits in lexical order, calls fn for the root first, and does not follow symlinks.
	err := filepath.WalkDir(".", func(p string, d fs.DirEntry, err error) error {
		if err != nil {
			return err // for example a directory that cannot be read
		}
		if d.IsDir() && d.Name() == ".git" {
			return filepath.SkipDir // do not descend
		}
		kind := "file"
		switch {
		case d.IsDir():
			kind = "dir"
		case d.Type()&fs.ModeSymlink != 0:
			kind = "symlink"
		}
		fmt.Printf("%-18s %s\n", p, kind)
		return nil
	})
	fmt.Println("WalkDir:", err)

	// SkipAll (Go 1.20) stops the whole walk successfully.
	n := 0
	err = filepath.WalkDir(".", func(p string, d fs.DirEntry, err error) error {
		if filepath.Ext(p) == ".json" {
			n++
			if n == 2 {
				return filepath.SkipAll
			}
		}
		return nil
	})
	fmt.Println("stopped after", n, "JSON files, err =", err)

	// Glob returns the matches of one pattern level at a time (no **).
	m, _ := filepath.Glob("*/*.json")
	fmt.Println(m)

	// EvalSymlinks resolves links and cleans the result.
	resolved, err := filepath.EvalSymlinks("link-to-a/one.json")
	fmt.Println(resolved, err)
}
.                  dir
a                  dir
a/one.json         file
a/two.txt          file
b                  dir
b/deep             dir
b/deep/four.json   file
b/three.json       file
link-to-a          symlink
WalkDir: <nil>
stopped after 2 JSON files, err = <nil>
[a/one.json b/three.json link-to-a/one.json]
a/one.json <nil>

IsLocal and Localize: validating untrusted names

Whenever a file name comes from outside the program (an upload, an archive entry, a URL path, a database column), the question is whether joining it onto a base directory can escape that directory. filepath.IsLocal(name) (Go 1.20) answers it lexically: it is true when the name is non-empty, not absolute, never leaves the directory it is evaluated in even after .. elements are resolved, and (on Windows) is not a reserved device name. If it returns true then filepath.Join(base, name) is always inside base. filepath.Localize(slashPath) (Go 1.23) converts a slash-separated io/fs-style name to an OS path and fails if the name is not valid or not representable (a backslash in a name is rejected on Windows, where \ is a separator).

package main

import (
	"fmt"
	"os"
	"path/filepath"
	"strings"
)

// naiveJoin is the classic hand-rolled check: join, then test the prefix.
func naiveJoin(base, name string) (string, error) {
	p := filepath.Join(base, name)
	if !strings.HasPrefix(p, filepath.Clean(base)+string(filepath.Separator)) {
		return "", fmt.Errorf("%q escapes %s", name, filepath.Base(base))
	}
	return p, nil
}

func main() {
	for _, name := range []string{"loans.json", "2026/loans.json", "a/../b", "../x", "a/../../x", "/etc/passwd", "", ".", "..", "a/./b"} {
		fmt.Printf("IsLocal(%q) = %t\n", name, filepath.IsLocal(name))
	}

	// Localize converts a slash path (such as an fs.FS or URL path) to an OS path or fails.
	for _, name := range []string{"2026/loans.json", "../x", `a\b`} {
		p, err := filepath.Localize(name)
		fmt.Printf("Localize(%q) = %q, %v\n", name, p, err)
	}

	// IsLocal and the prefix check are lexical: neither sees symbolic links.
	base, _ := os.MkdirTemp("", "shelf-*")
	defer os.RemoveAll(base)
	uploads := filepath.Join(base, "uploads")
	os.Mkdir(uploads, 0o755)
	os.WriteFile(filepath.Join(base, "secret.txt"), []byte("outside"), 0o644)
	os.Symlink("..", filepath.Join(uploads, "up"))

	name := "up/secret.txt"
	fmt.Println("IsLocal:", filepath.IsLocal(name))
	p, err := naiveJoin(uploads, name)
	fmt.Println("naive check passes:", err == nil)
	b, _ := os.ReadFile(p)
	fmt.Printf("os.ReadFile read %q\n", b)

	_, err = os.OpenInRoot(uploads, name)
	fmt.Println("os.OpenInRoot:", strings.ReplaceAll(err.Error(), base, "$BASE"))
}
IsLocal("loans.json") = true
IsLocal("2026/loans.json") = true
IsLocal("a/../b") = true
IsLocal("../x") = false
IsLocal("a/../../x") = false
IsLocal("/etc/passwd") = false
IsLocal("") = false
IsLocal(".") = true
IsLocal("..") = false
IsLocal("a/./b") = true
Localize("2026/loans.json") = "2026/loans.json", <nil>
Localize("../x") = "", invalid path
Localize("a\\b") = "a\\b", <nil>
IsLocal: true
naive check passes: true
os.ReadFile read "outside"
os.OpenInRoot: openat up/secret.txt: path escapes from parent

The last lines are the important part. IsLocal and every prefix check are lexical: they see the string up/secret.txt, which is local, and do not know that up is a symbolic link to the parent directory. Anyone who can create files in the directory (an archive extractor can) can place such a link. Checking names is necessary but not sufficient; os.Root closes the gap.

io/fs: a read-only file-system abstraction

Go 1.16 added package io/fs to describe a file system as an interface, which let the standard library’s file-consuming code (html/template.ParseFS, http.FileServerFS, http.FS, archive/zip, testing/fstest) work on files from the disk, from the executable, from an archive or from memory, interchangeably. The core is deliberately small:

type FS interface {
	Open(name string) (File, error)
}

type File interface {
	Stat() (FileInfo, error)
	Read([]byte) (int, error)
	Close() error
}

Source: fs.FS and fs.File

Names passed to an fs.FS are slash-separated, unrooted and clean: no leading /, no . or .. elements, no empty elements, with . naming the root. fs.ValidPath(name) tests this, and an Open of an invalid name must fail with fs.ErrInvalid. This is also the reason the interface is safe against ../ names, and why fs.FS names are slash paths on Windows too (filepath.Localize converts them).

Optional interfaces let an implementation provide faster or richer operations, and the package-level helper functions use them when present and fall back to Open otherwise:

Interface Method Helper function

fs.ReadFileFS

ReadFile(name)

fs.ReadFile(fsys, name)

fs.ReadDirFS

ReadDir(name)

fs.ReadDir(fsys, name) (sorted)

fs.StatFS

Stat(name)

fs.Stat(fsys, name)

fs.GlobFS

Glob(pattern)

fs.Glob(fsys, pattern)

fs.SubFS

Sub(dir)

fs.Sub(fsys, dir): a view rooted at a subdirectory

fs.ReadLinkFS (Go 1.25)

ReadLink(name), Lstat(name)

fs.ReadLink, fs.Lstat: symbolic links in a file system

Other pieces: fs.DirEntry (name, IsDir, Type, Info), fs.FileInfo (name, size, mode, modification time), fs.FileMode, fs.ReadDirFile (a File that can list a directory), fs.WalkDir and the fs.SkipDir/fs.SkipAll results, fs.FormatFileInfo and fs.FormatDirEntry (Go 1.21; the same text ls-style listings use), and fs.PathError with fs.ErrNotExist, ErrExist, ErrPermission, ErrInvalid and ErrClosed. An fs.FS is read-only by design; there is no standard interface for creating files (writing goes through os, or through os.Root).

Writing code against fs.FS

A function that accepts an fs.FS does not care where the files live, and a test can pass an in-memory fstest.MapFS:

package main

import (
	"fmt"
	"io/fs"
	"os"
	"path"
	"testing/fstest"
	"time"
)

// countTitles accepts any fs.FS: the OS, an embed.FS, a zip archive or a test double.
func countTitles(fsys fs.FS, dir string) (int, error) {
	n := 0
	err := fs.WalkDir(fsys, dir, func(p string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if !d.IsDir() && path.Ext(p) == ".txt" {
			b, err := fs.ReadFile(fsys, p)
			if err != nil {
				return err
			}
			if len(b) > 0 {
				n++
			}
		}
		return nil
	})
	return n, err
}

func main() {
	mem := fstest.MapFS{
		"shelf/go.txt":       {Data: []byte("The Go Programming Language"), ModTime: time.Date(2026, 10, 18, 9, 30, 0, 0, time.UTC)},
		"shelf/rust.txt":     {Data: []byte("Programming Rust")},
		"shelf/empty.txt":    {Data: nil},
		"shelf/sub/more.txt": {Data: []byte("Learning Go")},
		"shelf/readme.md":    {Data: []byte("# shelf")},
	}
	fmt.Println(countTitles(mem, "shelf"))

	// The same function runs against a real directory.
	dir, _ := os.MkdirTemp("", "shelf-*")
	defer os.RemoveAll(dir)
	os.WriteFile(dir+"/a.txt", []byte("x"), 0o644)
	fmt.Println(countTitles(os.DirFS(dir), "."))

	// fs names are slash-separated, unrooted and clean: ValidPath says whether a name qualifies.
	for _, name := range []string{"a/b", "/a", "a/../b", "./a", "a//b", "."} {
		fmt.Printf("ValidPath(%q) = %t\n", name, fs.ValidPath(name))
	}
	_, err := mem.Open("/shelf/go.txt")
	fmt.Println(err)

	// Stat, ReadDir, Glob, Sub work on any FS.
	info, _ := fs.Stat(mem, "shelf/go.txt")
	fmt.Println(fs.FormatFileInfo(info))
	entries, _ := fs.ReadDir(mem, "shelf")
	for _, e := range entries {
		fmt.Print(fs.FormatDirEntry(e), " ")
	}
	fmt.Println()
	matches, _ := fs.Glob(mem, "shelf/*.txt")
	fmt.Println(matches)
	sub, _ := fs.Sub(mem, "shelf/sub")
	b, _ := fs.ReadFile(sub, "more.txt")
	fmt.Println(string(b))
}
3 <nil>
1 <nil>
ValidPath("a/b") = true
ValidPath("/a") = false
ValidPath("a/../b") = false
ValidPath("./a") = false
ValidPath("a//b") = false
ValidPath(".") = true
open /shelf/go.txt: file does not exist
---------- 27 2026-10-18 09:30:00 go.txt
- empty.txt - go.txt - readme.md - rust.txt d sub/
[shelf/empty.txt shelf/go.txt shelf/rust.txt]
Learning Go

Source: io/fs and fstest.MapFS

os.DirFS(dir) gives the same view of a real directory. The name is accurate in what it does not do: it is a convenience over os.Open(dir + "/" + name) and does not stop a symbolic link inside dir from leading out of it (use Root.FS() for that). Its result implements StatFS, ReadFileFS, ReadDirFS and (since Go 1.25) ReadLinkFS. Note too that DirFS with a relative directory is evaluated relative to the working directory at each call, so a later os.Chdir changes what it points at.

Implementing fs.FS and testing it

An implementation needs only Open, but the contract has many corners: directory listings, Stat consistency, error types and seeking. fstest.TestFS(fsys, expected...) walks a file system and checks them all; it is to fs.FS what iotest.TestReader is to io.Reader. The example is a wrapper that hides in-progress .tmp files. The first version filters only Open, and TestFS immediately finds that directory listings still show the hidden file:

package main

import (
	"errors"
	"fmt"
	"io"
	"io/fs"
	"strings"
	"testing/fstest"
)

func hidden(name string) bool { return strings.HasSuffix(name, ".tmp") }

// naiveFS hides ".tmp" files in Open but forgets that directories list their children too.
type naiveFS struct{ inner fs.FS }

func (v naiveFS) Open(name string) (fs.File, error) {
	if hidden(name) {
		return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
	}
	return v.inner.Open(name)
}

// visibleFS also filters directory listings, both through Open and through ReadDir.
type visibleFS struct{ inner fs.FS }

func (v visibleFS) Open(name string) (fs.File, error) {
	if hidden(name) {
		return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
	}
	f, err := v.inner.Open(name)
	if err != nil {
		return nil, err
	}
	rd, ok := f.(fs.ReadDirFile)
	if !ok {
		return f, nil
	}
	all, err := rd.ReadDir(-1)
	if err != nil {
		f.Close()
		return nil, err
	}
	return &dirFile{File: f, entries: filter(all)}, nil
}

func (v visibleFS) ReadDir(name string) ([]fs.DirEntry, error) {
	entries, err := fs.ReadDir(v.inner, name)
	return filter(entries), err
}

func filter(entries []fs.DirEntry) []fs.DirEntry {
	var out []fs.DirEntry
	for _, e := range entries {
		if !hidden(e.Name()) {
			out = append(out, e)
		}
	}
	return out
}

// dirFile serves a precomputed, filtered listing through the fs.ReadDirFile contract.
type dirFile struct {
	fs.File
	entries []fs.DirEntry
	off     int
}

func (d *dirFile) ReadDir(n int) ([]fs.DirEntry, error) {
	rest := d.entries[d.off:]
	if n <= 0 {
		d.off = len(d.entries)
		return rest, nil
	}
	if len(rest) == 0 {
		return nil, io.EOF
	}
	n = min(n, len(rest))
	d.off += n
	return rest[:n], nil
}

func main() {
	mem := fstest.MapFS{
		"books.json":      {Data: []byte("[]")},
		"books.json.tmp":  {Data: []byte("half written")},
		"2026/loans.json": {Data: []byte("[]")},
	}

	err := fstest.TestFS(naiveFS{mem}, "books.json", "2026/loans.json")
	lines := strings.Split(err.Error(), "\n")
	fmt.Println("naive:", lines[0])
	fmt.Println("  ", lines[1])

	v := visibleFS{mem}
	entries, _ := fs.ReadDir(v, ".")
	for _, e := range entries {
		fmt.Println(e.Name())
	}
	_, err = fs.ReadFile(v, "books.json.tmp")
	fmt.Println(err, errors.Is(err, fs.ErrNotExist))
	fmt.Println("visible: TestFS:", fstest.TestFS(v, "books.json", "2026/loans.json"))
}
naive: TestFS found errors:
   books.json.tmp: Open: open books.json.tmp: file does not exist
2026
books.json
open books.json.tmp: file does not exist true
visible: TestFS: <nil>

Source: fstest.TestFS

A wrapper that changes what a directory contains has to change it consistently in all three places a directory is observed: Open of the directory (which returns a ReadDirFile), ReadDir and Stat/Info of entries. That is why the common wrappers (fs.Sub, http.FS) are thin, and why a filtering FS is better built on fs.WalkDir filtering at the call site when that is possible.

Copying a file system: os.CopyFS

os.CopyFS(dir, fsys) (Go 1.23) writes the contents of an fs.FS into a directory on disk, creating dir when needed. Files get mode 0o666 plus any execute bits from the source, directories 0o777, both before the umask. It never overwrites: if a destination file exists the call fails with an error for which errors.Is(err, fs.ErrExist) is true, and it stops at the first error, so a partial copy is possible. Since Go 1.25 it copies symbolic links when the source implements fs.ReadLinkFS. It is how you extract an embedded default configuration or a template set to disk.

package main

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"testing/fstest"
)

func main() {
	src := fstest.MapFS{
		"templates/book.html": {Data: []byte("<h1>{{.Title}}</h1>"), Mode: 0o644},
		"bin/export.sh":       {Data: []byte("#!/bin/sh\n"), Mode: 0o755},
		"README":              {Data: []byte("shelf\n")},
	}
	base, _ := os.MkdirTemp("", "shelf-*")
	defer os.RemoveAll(base)
	dst := filepath.Join(base, "site") // CopyFS creates dst when it is missing

	fmt.Println("first copy:", os.CopyFS(dst, src))

	filepath.WalkDir(dst, func(p string, d fs.DirEntry, err error) error {
		rel, _ := filepath.Rel(dst, p)
		info, _ := d.Info()
		fmt.Printf("  %-22s %v\n", rel, info.Mode().Perm())
		return nil
	})

	// It never overwrites: an existing file makes the whole call fail with fs.ErrExist.
	err := os.CopyFS(dst, src)
	fmt.Println("second copy is ErrExist:", errors.Is(err, fs.ErrExist))
}
first copy: <nil>
  .                      -rwxr-xr-x
  README                 -rw-r--r--
  bin                    -rwxr-xr-x
  bin/export.sh          -rwxr-xr-x
  templates              -rwxr-xr-x
  templates/book.html    -rw-r--r--
second copy is ErrExist: true

Source: os.CopyFS

Related helpers: archive/zip.Writer.AddFS (Go 1.22) and archive/tar.Writer.AddFS (Go 1.22) write an fs.FS into an archive, and zip.Reader and tar-based readers go the other way, since a *zip.Reader is itself an fs.FS. http.FS(fsys) adapts one to the older http.FileSystem, and http.FileServerFS(fsys) and http.ServeFileFS serve one directly (see net/http Clients and Servers).

embed.FS: files inside the binary

The //go:embed directive copies files into the executable at build time, so a program can ship its templates, SQL migrations, default configuration or web assets as a single file. The directive itself, its patterns and its restrictions are covered in Directives, Code Generation and Embedding; this section is about the value you get. A variable of type string or []byte receives one file; a variable of type embed.FS receives a tree and is a read-only fs.FS that also implements ReadDirFS and ReadFileFS.

package main

import (
	"embed"
	"fmt"
	"io/fs"
)

//go:embed VERSION
var version string // a single file as a string

//go:embed assets/titles.txt
var titles []byte // or as bytes

//go:embed assets
var plain embed.FS // directory: files starting with '.' or '_' are left out

//go:embed all:assets
var everything embed.FS // the all: prefix keeps them

func main() {
	fmt.Printf("version=%q titles=%d bytes\n", version, len(titles))

	list := func(label string, fsys fs.FS) {
		fmt.Println(label)
		fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
			fmt.Println("  ", p)
			return err
		})
	}
	list("assets (default rules):", plain)
	list("all:assets:", everything)

	// embed.FS is a read-only fs.FS, so fs.Sub, fs.ReadFile and fs.Glob apply.
	tmpl, _ := fs.Sub(plain, "assets/templates")
	b, err := fs.ReadFile(tmpl, "book.html")
	fmt.Printf("%q %v\n", b, err)
	m, _ := fs.Glob(plain, "assets/*.txt")
	fmt.Println(m)

	// Files report a zero modification time and read-only permissions.
	info, _ := fs.Stat(plain, "assets/titles.txt")
	fmt.Println(info.Mode(), info.ModTime().IsZero(), info.Size())

	// An embed.FS is read-only (there is no Create); a missing name reports fs.ErrNotExist.
	_, err = plain.Open("assets/missing.txt")
	fmt.Println(err)
}

The files that the program embeds are:

VERSION
assets/titles.txt
assets/templates/book.html
assets/.hidden
assets/_draft.txt
version="0.1.0\n" titles=40 bytes
assets (default rules):
   .
   assets
   assets/templates
   assets/templates/book.html
   assets/titles.txt
all:assets:
   .
   assets
   assets/.hidden
   assets/_draft.txt
   assets/templates
   assets/templates/book.html
   assets/titles.txt
"<h1>{{.Title}}</h1>\n" <nil>
[assets/titles.txt]
-r--r--r-- true 40
open assets/missing.txt: file does not exist

Points to remember:

  • Embedding a directory leaves out files whose names begin with . or _; the all: prefix includes them. Other patterns are matched with path.Match rules and must match at least one file, otherwise the build fails.

  • Embedded files report a zero modification time and read-only modes, so http.FileServerFS serves them without Last-Modified; set cache headers yourself or put a content hash in the URL.

  • The embedded paths are fixed at build time. To let an operator override them, check a directory on disk first (an os.DirFS) and fall back to the embedded copy, or use os.CopyFS to write the defaults out.

  • Symbolic links are not followed when embedding (Go 1.25 added the embedfollowsymlinks GODEBUG setting, default 0, which can change that for symbolic links to regular files).

  • Embedded data counts toward the binary size and, once mapped, is shared read-only memory, not heap: embedding a 50 MB asset is cheap at run time but makes the executable 50 MB larger.

Traversal-resistant access with os.Root

Go 1.24 added os.Root, a handle to a directory whose methods can only reach files beneath that directory. The release notes call it directory-limited filesystem access, and the Go blog’s post Traversal-resistant file APIs explains the problem it solves. The problem is the path traversal class of vulnerability: a program takes a name from untrusted input (an upload, an archive entry, a URL path), joins it onto a base directory and opens the result, and the name escapes the base through .. elements or through a symbolic link. The checks in the previous sections are lexical and cannot see symbolic links, and checking and then opening is also racy: the file system can change between the check and the use. os.Root removes both problems by doing the resolution itself, one path element at a time, relative to an open directory handle, using the operating system’s mechanisms for that (the openat family of system calls on Unix).

Item Behaviour

os.OpenRoot(dir)

Opens a directory and returns a *os.Root; follows symbolic links in dir itself. Returns a *PathError.

os.OpenInRoot(dir, name)

One-shot: OpenRoot(dir) then open name; the root is released again. For a single file.

root.Name(), root.Close()

The directory name given to OpenRoot, and releasing the handle. After Close the methods fail with file already closed.

root.FS()

An fs.FS view of the root implementing StatFS, ReadFileFS, ReadDirFS and ReadLinkFS; it stays confined.

root.OpenRoot(name)

A nested root for a subdirectory, equally confined.

The methods mirror the functions of package os, in two waves:

Since Methods

Go 1.24

Open, Create, OpenFile, OpenRoot, Mkdir, Remove, Stat, Lstat, FS, Name, Close

Go 1.25

Chmod, Chown, Lchown, Chtimes, Link, Symlink, Readlink, Rename, MkdirAll, RemoveAll, ReadFile, WriteFile

(The Go 1.24 release notes list the first set and the Go 1.25 notes the second. The API files in the toolchain confirm the split, including that ReadFile and WriteFile came in 1.25 and not 1.24, so code that must build with a Go 1.24 toolchain has to use Open and OpenFile plus io.ReadAll and io.Copy.)

The rules, from the package documentation:

  • Any path element that would resolve to a location outside the root fails, whether it gets there by .., by an absolute name, or by a symbolic link. Names may reference the root itself as ., and .. is fine when it stays inside.

  • Symbolic links are followed, provided their targets stay inside the root. Absolute symbolic links are rejected. Root.Symlink does not validate its first argument, so a link pointing outside the root can be created, but following it fails.

  • The directory handle is held open (a file descriptor on Unix, a handle on Windows), so renaming or moving the root directory does not change what the Root refers to, which also defeats a race in which the directory is swapped with a link.

  • Safe for use from multiple goroutines.

  • The failure is a *fs.PathError whose Err is a fixed value with the text path escapes from parent, and whose Op names the system call (openat here, or mkdirat, … depending on the platform).

package main

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"strings"
)

func main() {
	base, _ := os.MkdirTemp("", "shelf-*")
	defer os.RemoveAll(base)
	uploads := filepath.Join(base, "uploads")
	os.Mkdir(uploads, 0o755)
	os.WriteFile(filepath.Join(base, "secret.txt"), []byte("outside the root"), 0o644)
	clean := func(err error) string {
		if err == nil {
			return "<nil>"
		}
		return strings.ReplaceAll(err.Error(), base, "$BASE")
	}

	root, err := os.OpenRoot(uploads)
	if err != nil {
		fmt.Println(err)
		return
	}
	defer root.Close()
	fmt.Println("Name():", strings.ReplaceAll(root.Name(), base, "$BASE"))

	// Everything inside works like the os functions.
	fmt.Println("MkdirAll:", clean(root.MkdirAll("2026/10", 0o755)))
	fmt.Println("WriteFile:", clean(root.WriteFile("2026/10/loans.json", []byte("[]"), 0o644)))
	b, err := root.ReadFile("2026/10/loans.json")
	fmt.Printf("ReadFile: %q %v\n", b, err)
	fmt.Println("Rename:", clean(root.Rename("2026/10/loans.json", "2026/loans.json")))
	st, err := root.Stat("2026/loans.json")
	fmt.Println("Stat:", st.Name(), st.Size(), err)

	// Escapes are rejected by name...
	_, err = root.ReadFile("../secret.txt")
	fmt.Println(".. escape:", clean(err))
	_, err = root.Open("2026/../../secret.txt")
	fmt.Println("inner .. escape:", clean(err))
	_, err = root.Open(filepath.Join(base, "secret.txt"))
	fmt.Println("absolute path:", clean(err))

	// ...but .. that stays inside is fine, and "." names the root itself.
	_, err = root.Stat("2026/10/../loans.json")
	fmt.Println("harmless ..:", clean(err))
	st, _ = root.Stat(".")
	fmt.Println("dot is dir:", st.IsDir())

	// ...and by symbolic links.
	os.Symlink("../secret.txt", filepath.Join(uploads, "leak"))
	_, err = root.ReadFile("leak")
	fmt.Println("symlink out:", clean(err))
	os.Symlink("2026/loans.json", filepath.Join(uploads, "inside"))
	b, err = root.ReadFile("inside")
	fmt.Printf("symlink inside: %q %v\n", b, err)
	os.Symlink(filepath.Join(base, "secret.txt"), filepath.Join(uploads, "abs"))
	_, err = root.ReadFile("abs")
	fmt.Println("absolute symlink:", clean(err))

	// The os package, by contrast, follows the link out of the directory.
	b, err = os.ReadFile(filepath.Join(uploads, "leak"))
	fmt.Printf("os.ReadFile(leak): %q %v\n", b, err)

	// The failure is a *fs.PathError; the cause is a fixed error value.
	var pe *fs.PathError
	_, err = root.Open("../secret.txt")
	fmt.Println(errors.As(err, &pe), pe.Op, pe.Err)
}
Name(): $BASE/uploads
MkdirAll: <nil>
WriteFile: <nil>
ReadFile: "[]" <nil>
Rename: <nil>
Stat: loans.json 2 <nil>
.. escape: openat ../secret.txt: path escapes from parent
inner .. escape: openat 2026/../../secret.txt: path escapes from parent
absolute path: openat $BASE/secret.txt: path escapes from parent
harmless ..: <nil>
dot is dir: true
symlink out: openat leak: path escapes from parent
symlink inside: "[]" <nil>
absolute symlink: openat abs: path escapes from parent
os.ReadFile(leak): "outside the root" <nil>
true openat path escapes from parent

The next example shows Root.FS() (which makes an fs.FS that cannot be led out of its directory, in contrast to os.DirFS), nested roots, OpenInRoot and the behaviour after Close:

package main

import (
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"strings"
)

func main() {
	base, _ := os.MkdirTemp("", "shelf-*")
	defer os.RemoveAll(base)
	os.MkdirAll(filepath.Join(base, "site", "css"), 0o755)
	os.WriteFile(filepath.Join(base, "site", "index.html"), []byte("<h1>shelf</h1>"), 0o644)
	os.WriteFile(filepath.Join(base, "site", "css", "app.css"), []byte("body{}"), 0o644)
	os.WriteFile(filepath.Join(base, "secret.txt"), []byte("outside"), 0o644)
	os.Symlink("../secret.txt", filepath.Join(base, "site", "leak.txt"))

	root, _ := os.OpenRoot(filepath.Join(base, "site"))
	defer root.Close()

	// Root.FS adapts the root to fs.FS, so http.FS, fs.WalkDir, template.ParseFS and friends stay confined.
	fsys := root.FS()
	fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
		if err != nil {
			fmt.Println("  ", p, "error:", strings.ReplaceAll(err.Error(), base, "$BASE"))
			return nil
		}
		fmt.Println("  ", p)
		return nil
	})
	_, err := fs.ReadFile(fsys, "leak.txt")
	fmt.Println("leak via fs.FS:", strings.ReplaceAll(err.Error(), base, "$BASE"))
	_, err = fs.ReadFile(os.DirFS(filepath.Join(base, "site")), "leak.txt")
	fmt.Println("leak via os.DirFS:", err)

	// A sub-root is itself confined; Root.OpenRoot cannot go back up.
	css, err := root.OpenRoot("css")
	fmt.Println("OpenRoot(css):", err)
	_, err = css.Open("../index.html")
	fmt.Println("from the sub-root:", strings.ReplaceAll(err.Error(), base, "$BASE"))
	css.Close()

	// OpenInRoot is the one-call form for a single file.
	f, err := os.OpenInRoot(filepath.Join(base, "site"), "index.html")
	fmt.Println("OpenInRoot:", err)
	f.Close()
	_, err = os.OpenInRoot(filepath.Join(base, "site"), "../secret.txt")
	fmt.Println("OpenInRoot escape:", strings.ReplaceAll(err.Error(), base, "$BASE"))

	// A closed root refuses further work.
	root.Close()
	_, err = root.Open("index.html")
	fmt.Println("after Close:", strings.ReplaceAll(err.Error(), base, "$BASE"))
}
   .
   css
   css/app.css
   index.html
   leak.txt
leak via fs.FS: openat leak.txt: path escapes from parent
leak via os.DirFS: <nil>
OpenRoot(css): <nil>
from the sub-root: openat ../index.html: path escapes from parent
OpenInRoot: <nil>
OpenInRoot escape: openat ../secret.txt: path escapes from parent
after Close: openat index.html: file already closed

Archive extraction is the case that most often needs os.Root: archive entry names are attacker-controlled (the "zip slip" vulnerability class), and the archive/zip and archive/tar writers do not validate names, so a hostile archive can contain ../evil.txt. Go 1.20 added optional rejection when reading (GODEBUG=zipinsecurepath=0, tarinsecurepath=0, both still defaulting to 1, which allows them), but the robust fix is to extract through a root:

package main

import (
	"archive/zip"
	"bytes"
	"fmt"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"strings"
)

// makeArchive builds a zip in memory. Writers do not validate names, so an
// archive can contain entries such as "../evil.txt".
func makeArchive() []byte {
	var buf bytes.Buffer
	zw := zip.NewWriter(&buf)
	for _, name := range []string{"books/go.txt", "../evil.txt"} {
		w, _ := zw.Create(name)
		io.WriteString(w, "content of "+name)
	}
	zw.Close()
	return buf.Bytes()
}

// extractNaive joins the entry name onto the destination without checking it.
func extractNaive(zr *zip.Reader, dest string) error {
	for _, f := range zr.File {
		target := filepath.Join(dest, f.Name)
		if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
			return err
		}
		rc, err := f.Open()
		if err != nil {
			return err
		}
		data, _ := io.ReadAll(rc)
		rc.Close()
		if err := os.WriteFile(target, data, 0o644); err != nil {
			return err
		}
	}
	return nil
}

// extractRoot lets os.Root refuse any entry that would leave dest.
func extractRoot(zr *zip.Reader, dest string) error {
	root, err := os.OpenRoot(dest)
	if err != nil {
		return err
	}
	defer root.Close()
	for _, f := range zr.File {
		if err := root.MkdirAll(filepath.Dir(f.Name), 0o755); err != nil {
			fmt.Printf("  skipped %s: %v\n", f.Name, strings.ReplaceAll(err.Error(), dest, "$DEST"))
			continue
		}
		rc, err := f.Open()
		if err != nil {
			return err
		}
		data, _ := io.ReadAll(rc)
		rc.Close()
		if err := root.WriteFile(f.Name, data, 0o644); err != nil {
			fmt.Printf("  skipped %s: %v\n", f.Name, strings.ReplaceAll(err.Error(), dest, "$DEST"))
		}
	}
	return nil
}

func main() {
	data := makeArchive()
	zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
	fmt.Println("NewReader:", err)

	base, _ := os.MkdirTemp("", "shelf-*")
	defer os.RemoveAll(base)

	fmt.Println("naive extraction:")
	dest1 := filepath.Join(base, "one")
	os.Mkdir(dest1, 0o755)
	fmt.Println("  err:", extractNaive(zr, dest1))
	_, err = os.Stat(filepath.Join(base, "evil.txt"))
	fmt.Println("  evil.txt escaped into the parent directory:", err == nil)
	os.Remove(filepath.Join(base, "evil.txt"))

	fmt.Println("extraction through os.Root:")
	dest2 := filepath.Join(base, "two")
	os.Mkdir(dest2, 0o755)
	fmt.Println("  err:", extractRoot(zr, dest2))
	_, err = os.Stat(filepath.Join(base, "evil.txt"))
	fmt.Println("  evil.txt escaped:", err == nil)

	// zip.Reader is an fs.FS, and fs.FS names are validated.
	_, err = zr.Open("../evil.txt")
	fmt.Println("zr.Open(../evil.txt):", err, fs.ValidPath("../evil.txt"))
}
NewReader: <nil>
naive extraction:
  err: <nil>
  evil.txt escaped into the parent directory: true
extraction through os.Root:
  skipped ../evil.txt: mkdirat ..: path escapes from parent
  err: <nil>
  evil.txt escaped: false
zr.Open(../evil.txt): open ../evil.txt: invalid argument false

Source: archive/zip and os.Root

The naive extractor wrote a file outside its destination; the root-based extractor refused the entry. The Open method of the *zip.Reader itself (it is an fs.FS) rejects such a name, because it is not a valid fs path.

Limits to know, taken from the documentation:

  • Root does not prohibit crossing file-system boundaries, Linux bind mounts, /proc special files or access to Unix device files. It protects against a path leaving a directory tree, not against everything a privileged path can reach.

  • On Unix, Root.Chmod, Root.Chown and Root.Chtimes can race: if the target changes from a regular file to a symbolic link while the call is in progress, the operation may be applied to the link and not to its target.

  • On Windows, names may not refer to reserved device names such as NUL and COM1. On GOOS=js a Root is vulnerable to time-of-check-to-time-of-use attacks and cannot guarantee confinement; on plan9 and js it names a directory and does not track it across renames; on wasip1 Root.Chmod is not supported.

  • It is not a sandbox against other processes that modify the directory, and it does not limit what the files contain.

Use os.Root for any code that opens a name supplied by someone else. Use filepath.IsLocal as a cheap first filter and for validating names that are stored, but not as the protection. Use os.DirFS only for trusted directories.

A reader chain

The pieces fit together into pipelines. The diagram shows the chain for importing a compressed catalogue: the file supplies raw bytes, a TeeReader counts them for a progress display, the gzip reader decompresses, a bufio.Reader batches reads, and a JSON decoder turns the stream into values, one array element at a time. The export direction is the mirror image, and its layers have to be closed from the outside in: flush the encoder’s buffer into gzip, close gzip (which writes its trailer), flush the buffer, then close the file.

flowchart LR subgraph Import direction LR F["*os.File
raw bytes"] --> T["io.TeeReader
counts bytes"] T --> Z["gzip.Reader
inflates"] Z --> B["bufio.Reader
4 KiB buffer"] B --> D["json.Decoder
Token, More, Decode"] D --> V["Book values
one at a time"] end T -. "Write" .-> C["byteCounter"] subgraph Export direction LR E["json.Encoder"] --> ZW["gzip.Writer"] ZW --> BW["bufio.Writer"] BW --> FW["*os.File"] end
package main

import (
	"bufio"
	"compress/gzip"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
)

type Book struct {
	ISBN  string `json:"isbn"`
	Title string `json:"title"`
}

// byteCounter counts what passes through a TeeReader.
type byteCounter struct{ n int64 }

func (c *byteCounter) Write(p []byte) (int, error) { c.n += int64(len(p)); return len(p), nil }

// export writes books as a gzip-compressed JSON array: Encoder -> gzip.Writer -> bufio.Writer -> file.
func export(path string, books []Book) (err error) {
	f, err := os.Create(path)
	if err != nil {
		return err
	}
	defer func() { err = errors.Join(err, f.Close()) }()

	bw := bufio.NewWriter(f)
	zw := gzip.NewWriter(bw)
	if err := json.NewEncoder(zw).Encode(books); err != nil {
		return err
	}
	// Close in the reverse order of construction: gzip trailer first, then the buffer.
	if err := zw.Close(); err != nil {
		return err
	}
	return bw.Flush()
}

// importBooks reads the same file back: file -> TeeReader -> gzip.Reader -> bufio.Reader -> json.Decoder.
func importBooks(path string, handle func(Book) error) (compressed int64, err error) {
	f, err := os.Open(path)
	if err != nil {
		return 0, err
	}
	defer f.Close()

	var count byteCounter
	zr, err := gzip.NewReader(io.TeeReader(f, &count))
	if err != nil {
		return 0, err
	}
	defer zr.Close()

	dec := json.NewDecoder(bufio.NewReader(zr))
	if _, err := dec.Token(); err != nil { // opening '['
		return 0, err
	}
	for dec.More() { // stream one element at a time, never the whole array
		var b Book
		if err := dec.Decode(&b); err != nil {
			return count.n, err
		}
		if err := handle(b); err != nil {
			return count.n, err
		}
	}
	_, err = dec.Token() // closing ']'
	return count.n, err
}

func main() {
	dir, _ := os.MkdirTemp("", "shelf-*")
	defer os.RemoveAll(dir)
	p := filepath.Join(dir, "books.json.gz")

	books := []Book{
		{"978-0-13-419044-0", "The Go Programming Language"},
		{"978-1-49-207721-3", "Learning Go"},
		{"978-1-49-189-1", "Concurrency in Go"},
	}
	fmt.Println("export:", export(p, books))

	n, err := importBooks(p, func(b Book) error {
		fmt.Printf("  %s  %s\n", b.ISBN, b.Title)
		return nil
	})
	fmt.Println("import:", err, "- compressed bytes read:", n > 0)

	// Reading a file that is not gzip fails at the first layer, with a precise error.
	bad := filepath.Join(dir, "plain.json")
	os.WriteFile(bad, []byte(`[{"isbn":"1"}]`), 0o644)
	_, err = importBooks(bad, nil)
	fmt.Println("plain JSON through gzip:", err, errors.Is(err, gzip.ErrHeader))

	// A truncated archive fails in the middle of the stream.
	data, _ := os.ReadFile(p)
	os.WriteFile(bad, data[:len(data)/2], 0o644)
	_, err = importBooks(bad, func(Book) error { return nil })
	fmt.Println("truncated gzip:", err, errors.Is(err, io.ErrUnexpectedEOF))
}
export: <nil>
  978-0-13-419044-0  The Go Programming Language
  978-1-49-207721-3  Learning Go
  978-1-49-189-1  Concurrency in Go
import: <nil> - compressed bytes read: true
plain JSON through gzip: gzip: invalid header true
truncated gzip: unexpected EOF true

Each failure is reported by the layer that notices it: plain JSON fed to the gzip reader fails at the first read with gzip.ErrHeader, a truncated archive fails with io.ErrUnexpectedEOF in the middle of a value, and a handler error stops the stream early. Because the decoder pulls data through the whole chain on demand, memory use is bounded by the size of one element, not of the file. (The exact size of a compressed file depends on the Go release, since the flate encoder was made faster in Go 1.27; the example prints only a boolean.) Closing order, in the export direction, is the part that is easy to get wrong, and it is why the example closes gzip.Writer before flushing the bufio.Writer and closing the file.

Bounding input and common read mistakes

Untrusted input needs a limit before it needs anything else. io.LimitReader(r, n+1) followed by a length check, as in the example, detects an oversized body without reading it all; http.MaxBytesReader(w, r.Body, n) does the same for HTTP requests and also tells the server to close the connection. io.ReadAll(r) on an unbounded reader is a memory exhaustion bug waiting for the first large input.

package main

import (
	"errors"
	"fmt"
	"io"
	"io/fs"
	"os"
	"strings"
	"testing/iotest"
)

var errTooBig = errors.New("input exceeds limit")

// readLimited reads at most max bytes and reports an error, instead of silently truncating, when there is more.
func readLimited(r io.Reader, max int64) ([]byte, error) {
	b, err := io.ReadAll(io.LimitReader(r, max+1))
	if err != nil {
		return nil, err
	}
	if int64(len(b)) > max {
		return nil, fmt.Errorf("%w (%d bytes)", errTooBig, max)
	}
	return b, nil
}

func main() {
	// A bare Read may return fewer bytes than the buffer holds.
	buf := make([]byte, 4)
	n, _ := iotest.OneByteReader(strings.NewReader("ISBN-13")).Read(buf)
	fmt.Println("Read returned", n, "of", len(buf), "bytes")
	n, _ = io.ReadFull(iotest.OneByteReader(strings.NewReader("ISBN-13")), buf)
	fmt.Println("ReadFull returned", n, "of", len(buf), "bytes")

	// Bounding untrusted input.
	_, err := readLimited(strings.NewReader(strings.Repeat("x", 20)), 10)
	fmt.Println(err, errors.Is(err, errTooBig))
	b, err := readLimited(strings.NewReader("small"), 10)
	fmt.Println(string(b), err)

	// os.IsNotExist predates wrapping: it does not look inside fmt.Errorf("%w") chains.
	_, err = os.Open("/definitely/missing")
	wrapped := fmt.Errorf("load config: %w", err)
	fmt.Println("os.IsNotExist(wrapped):", os.IsNotExist(wrapped))
	fmt.Println("errors.Is(wrapped, fs.ErrNotExist):", errors.Is(wrapped, fs.ErrNotExist))
}
Read returned 1 of 4 bytes
ReadFull returned 4 of 4 bytes
input exceeds limit (10 bytes) true
small <nil>
os.IsNotExist(wrapped): false
errors.Is(wrapped, fs.ErrNotExist): true

The io/ioutil migration

Package io/ioutil collected the file and stream helpers of early Go. Go 1.16 moved every function to io or os and deprecated the package; the old functions are one-line wrappers that will stay forever under the Go 1 compatibility promise, but new code and a review of old code should use the replacements. There is no go vet check; the deprecation shows up in editors, in staticcheck (SA1019) and in gopls.

io/ioutil Replacement Notes

ioutil.ReadAll(r)

io.ReadAll(r)

Identical.

ioutil.ReadFile(name)

os.ReadFile(name)

Identical.

ioutil.WriteFile(name, data, perm)

os.WriteFile(name, data, perm)

Identical.

ioutil.ReadDir(dir)

os.ReadDir(dir)

Returns []fs.DirEntry (cheaper), not []fs.FileInfo; call entry.Info() when you need size or time. Still sorted by name.

ioutil.NopCloser(r)

io.NopCloser(r)

Identical.

ioutil.Discard

io.Discard

Identical.

ioutil.TempFile(dir, pattern)

os.CreateTemp(dir, pattern)

Identical.

ioutil.TempDir(dir, pattern)

os.MkdirTemp(dir, pattern)

Identical.

(Verified with go doc io/ioutil, which lists exactly these eight names, each with a deprecation note.) The two books this section is paired with, written for Go 1.15 and earlier, use ioutil throughout; the replacements are drop-in apart from ReadDir.

Compile errors, vet reports and panics

The compile-time failures in this area come from the interface rules. A pointer-receiver Read is in the method set of the pointer type only, so the value does not satisfy io.Reader (see Methods and Receivers):

type counter struct{ n int }

func (c *counter) Read(p []byte) (int, error) {
	c.n++
	return 0, io.EOF
}

func main() {
	var r io.Reader = counter{}
	b, _ := io.ReadAll(r)
	fmt.Println(len(b))
}
# example.com/shelf/_bad/ptr
./main.go:16:20: cannot use counter{} (value of struct type counter) as io.Reader value in variable declaration: counter does not implement io.Reader (method Read has pointer receiver)

Swapping the arguments of io.Copy and leaving an err unused produce the following (the listings of this section are the bodies of complete main.go files):

func main() {
	io.Copy(strings.NewReader("shelf"), os.Stdout) // arguments in the wrong order
	f, err := os.Open("books.csv")
	defer f.Close()
}
# example.com/shelf/_bad/order
./main.go:10:10: cannot use strings.NewReader("shelf") (value of type *strings.Reader) as io.Writer value in argument to io.Copy: *strings.Reader does not implement io.Writer (missing method Write)
./main.go:11:5: declared and not used: err

Source: io.Copy

go vet runs the type checker first, so it reports the same type error and stops:

# example.com/shelf/_bad/order
# [example.com/shelf/_bad/order]
vet: ./main.go:10:10: cannot use strings.NewReader("shelf") (value of type *strings.Reader) as io.Writer value in argument to io.Copy: *strings.Reader does not implement io.Writer (missing method Write)

The stdversion analyzer of go vet flags the use of a standard-library symbol that is newer than the go line of the module. This is how a project whose go.mod still says go 1.23 finds out that it calls os.OpenRoot (Go 1.24). The compiler itself accepts the call when built with a newer toolchain:

main.go:9:18: os.OpenRoot requires go1.24 or later (module is go1.23)
Runtime behaviour What happens

bufio.Scanner.Buffer or Split after Scan

Panics (Buffer called after Scan, Split called after Scan).

Scanner token longer than the buffer maximum

Scan returns false and Err() is bufio.ErrTooLong; no panic.

io.CopyBuffer with a zero-length non-nil buffer

Panics (empty buffer in CopyBuffer).

Write to a closed *os.File

Error file already closed (os.ErrClosed); no panic.

Method on a nil *os.File

Error os.ErrInvalid; no panic. A defer f.Close() placed before the check of the open error is therefore a harmless no-op, but the code after it would go on to use a nil file, so check the error first.

Write after the reader of an io.Pipe closed

io.ErrClosedPipe.

strings.Builder copied after use

Panics on the next write (illegal use of non-zero Builder copied by value).

Reading a directory with os.ReadFile

Error is a directory (platform text).

os.Remove of a non-empty directory

Error directory not empty; use os.RemoveAll.

Pitfalls

  • Treating Read's n < len(p) as an error or as EOF. Neither is true. Use io.ReadFull for fixed-size records and loop on Read (or io.Copy, io.ReadAll) otherwise.

  • Looking at err before n. Read may return data and io.EOF together; process the bytes first.

  • Ignoring Close on files opened for writing, and forgetting Flush on a bufio.Writer. Both lose data silently.

  • defer f.Close() in a loop keeps every file open until the function returns and exhausts descriptors on a big directory. Put the body in a function (or a closure) so the defer runs per iteration.

  • os.ReadFile on a file of unknown size, or io.ReadAll on a network body, allocates as much as the input. Bound it.

  • Joining untrusted names with filepath.Join and trusting a prefix check. Use os.Root.

  • bufio.Scanner with long lines or binary data: it stops at 64 KiB per token. Raise it with Buffer or use bufio.Reader.

  • Keeping Scanner.Bytes() or the slice from ReadSlice past the next read: the memory is reused. Copy it, or use Text() / ReadString.

  • os.Exit or log.Fatal after defer: deferred cleanup does not run. Return an exit code from run instead.

  • Relying on os.Rename across file systems or mount points, which fails with a cross-device error. Copy and delete instead.

  • Comparing with os.IsNotExist on a wrapped error. Use errors.Is(err, fs.ErrNotExist).

  • Using os.Chdir for convenience in a program with goroutines: it changes the directory for all of them.

  • Assuming 0644 means 0o644. An unprefixed 644 is decimal.

  • Expecting time and Stat values from embed.FS to mean something: they are zero.

Comparison with other languages

Go splits what Java’s java.io/java.nio.file and Python’s open() combine. A Go reader is a bare byte source, with no encoding (text is bytes interpreted by the caller) and no buffering unless you add bufio; compare the layers of decorators in Java I/O and files. Rust’s Read, Write and BufRead traits correspond one-to-one to io.Reader, io.Writer and bufio.Reader, with the difference that Rust’s read returns a Result and Go’s returns a count and an error that the caller must examine (Rust I/O, Files, Processes and Environment). The C FILE* stream is closest to a bufio-wrapped *os.File (C input, output and files), and Python’s context managers (with open(...)) are what defer f.Close() approximates (Python files and context managers). The tooling that wraps a file system for tests (Java’s Jimfs, Python’s pyfakefs) is replaced in Go by the language-level fs.FS interface and fstest.MapFS.

Where the books are outdated or wrong

  • Bodner, chapter 11, introduces io.Reader and io.Writer, io.Copy, io.Closer and the compositional style correctly; its listings use ioutil where this page uses os and io (deprecated in Go 1.16), and it predates io/fs, os.DirFS, embed (all Go 1.16), os.CopyFS (1.23), os.Root (1.24 and 1.25), filepath.IsLocal (1.20) and filepath.WalkDir (1.16, with SkipAll in 1.20). None of those is missing from this page.

  • Vivien, chapter 10, covers custom and chained readers and writers, io.Pipe, TeeReader, SectionReader, MultiReader/MultiWriter, files and permissions, the standard streams, bufio and Scanner. It was written for Go 1.6 and 1.7: it uses ioutil, shows os.IsNotExist where errors.Is(err, fs.ErrNotExist) is the idiom, and predates io/fs, embed and os.Root. Its examples that ignore the error from Close on a file opened for writing are a bad practice, not an idiom; see Closing: errors on write paths. Slips in its file-API names (io.File, os.Writer, govet) are not carried over: the types are *os.File and io.Writer, and the tool is go vet.

  • Both books describe directory listings as []os.FileInfo (through ioutil.ReadDir); os.ReadDir returns []fs.DirEntry.

Version notes

Release Change

Go 1.16

io/fs, os.DirFS, embed, filepath.WalkDir, os.ReadFile/WriteFile/ReadDir/CreateTemp/MkdirTemp, io.ReadSeekCloser; io/ioutil deprecated.

Go 1.20

filepath.IsLocal, filepath.SkipAll, io.OffsetWriter; optional rejection of insecure zip and tar paths.

Go 1.21

fs.FormatFileInfo, fs.FormatDirEntry, bytes.Buffer.Available/AvailableBuffer.

Go 1.22

io.SectionReader.Outer; AddFS on zip.Writer and tar.Writer; os.File.WriteTo.

Go 1.23

os.CopyFS, filepath.Localize; Windows mount points are no longer symbolic links (winsymlink GODEBUG).

Go 1.24

os.Root, os.OpenRoot, os.OpenInRoot (first method set); strings.Lines/SplitSeq/FieldsSeq and the bytes equivalents.

Go 1.25

The rest of the Root methods (ReadFile, WriteFile, MkdirAll, RemoveAll, Rename, Symlink, Readlink, Link, Chmod, Chown, Lchown, Chtimes); fs.ReadLinkFS implemented by os.DirFS and Root.FS; os.CopyFS copies symbolic links; embedfollowsymlinks.

Go 1.26

io.ReadAll allocates less and is about twice as fast on large inputs; bytes.Buffer.Peek.

Go 1.27

The release notes list no change to io, bufio, os, io/fs or embed; strings.CutLast/bytes.CutLast and faster compress/flate (the encoded bytes of gzip output can differ from Go 1.26).