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) |
|---|---|---|
|
|
|
|
|
|
|
|
files, connections, pipes, response bodies |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.
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 tolen(p)bytes and returns how many it stored inn. It may return fewer thanlen(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,
Readreturnsio.EOF. It may return the final bytes together withio.EOFin the same call (n > 0, err == io.EOF), or return the bytes with a nil error and report0, io.EOFon the next call. Both are legal. -
Callers must process the
n > 0bytes before looking aterr. Code that handles the error first loses the last chunk of data from readers that return data andio.EOFtogether. -
A return of
0, nilmeans nothing happened; it is not end of file. Implementations are discouraged from doing it, and callers should simply callReadagain. Many wrappers (bufio.Reader, for one) give up withio.ErrNoProgressafter 100 consecutive empty reads. -
io.EOFis the only error that means "normal end". AReadmust returnio.EOFitself, never an error that wraps it, because callers compare with==. Any other error is a failure.io.ErrUnexpectedEOFis what helpers such asio.ReadFullreturn when the stream ends in the middle of a structure. -
Implementations must not retain
pafter returning, and a call withlen(p) == 0should return0.
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=""
Source: io.Reader and testing/iotest
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"
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 |
|---|---|
|
Copies from |
|
Copies exactly |
|
Like |
|
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. |
|
Fills |
|
Reads until at least |
|
Writes |
|
A reader that returns |
|
Concatenates readers: reads each to EOF in turn. |
|
Duplicates writes to every writer; stops at the first error. |
|
Returns a reader that writes everything it reads from |
|
A reader, seeker and |
|
A writer, seeker and |
|
A synchronous in-memory pipe: a connected |
|
Wraps a |
|
A |
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!"
Source: io.Copy, io.ReadFull and io.LimitReader
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....
Source: io.MultiWriter, io.TeeReader, io.SectionReader and io.OffsetWriter
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>
Source: io.Copy, io.WriterTo and io.ReaderFrom
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.Bufferis a growable byte buffer that is both aReaderand aWriter(and aByteScanner,RuneScanner,ReaderFrom,WriterToandStringWriter). The zero value is ready to use. BesidesWrite*andRead*it hasNext(n),Truncate(n),Reset(),Grow(n),Len(),Available()andAvailableBuffer()(Go 1.21, for appending into the buffer’s spare capacity), andPeek(n)(Go 1.26), which returns the next bytes without consuming them.Bytes()andString()return the unread portion;Bytes()aliases the buffer’s memory. -
*strings.Builderis 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), andGrowpreallocates. -
*strings.Readerand*bytes.Readerare read-only, seekable views of a string or a byte slice. They implementReader,ReaderAt,Seeker,WriterTo,ByteScannerandRuneScanner;Resetre-points them at new data, andSizereturns 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
Source: bytes.Buffer, strings.Builder and strings.Reader
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 |
|---|---|
|
Reads into |
|
Single byte or UTF-8 rune; each |
|
The next |
|
Skips |
|
Read up to and including the delimiter byte, growing as needed; at EOF without a delimiter they return the data read and |
|
Returns a slice into the buffer, valid until the next read. Fails with |
|
Returns a line without the end-of-line bytes and an |
|
Number of buffered bytes, buffer size, reuse with a new source. |
|
Writes the remaining data to |
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:
-
Flushis not optional. Whatever remains in the buffer when the program (or the function that owns the writer) ends is lost unlessFlushis called, and there is no finalizer to save you. The usual shape isdefer w.Flush()for a writer to something unimportant, and an explicitif 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;
Flushreturns it too. So the code can write many items without checking each one, and checkFlushonce, 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>
Source: bufio.Scanner.Buffer
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)
Source: bufio.Scanner.Split
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 |
|---|---|
|
Opens for reading ( |
|
Opens for reading and writing with |
|
The general form. |
|
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. |
|
|
|
A new file with a random name, mode |
|
The same for directories (mode |
|
One directory, or all missing ancestors (no error if it already exists). |
|
A file or empty directory, or a whole tree (no error if missing). |
|
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. |
|
|
|
All entries as |
|
Create or read links. |
|
Metadata and size by name. |
|
Whether two |
|
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
Source: fs.PathError and the io/fs error values
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
Source: os.File.SetDeadline and os.Pipe
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
Source: errors.Join and Working with Errors in Go 1.13
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
Source: os.LookupEnv and os.UserConfigDir
(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
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 |
|---|---|
|
Joins with the separator and |
|
Lexical normalisation: collapses repeated separators, removes |
|
Directory and file parts, last element ( |
|
Absolute form (prepends the working directory), and tests. |
|
The relative path from |
|
Shell patterns: |
|
Splits a |
|
Convert between the OS separator and |
|
Resolves symbolic links and cleans the result; the path must exist. |
|
Lexical test for "stays inside the directory it is evaluated in" (below). Go 1.20. |
|
Converts a valid slash-separated |
|
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 |
|
|
Volume |
none |
|
Case |
sensitive on Linux, usually insensitive on macOS |
insensitive |
Reserved names |
none |
|
Permission bits |
all nine |
only read-only is meaningful |
Line endings |
|
|
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>
Source: filepath.WalkDir and filepath.Glob
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
Source: filepath.IsLocal and filepath.Localize
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
}
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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
Source: embed and Go 1.16 release notes
Points to remember:
-
Embedding a directory leaves out files whose names begin with
.or_; theall:prefix includes them. Other patterns are matched withpath.Matchrules and must match at least one file, otherwise the build fails. -
Embedded files report a zero modification time and read-only modes, so
http.FileServerFSserves them withoutLast-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 useos.CopyFSto write the defaults out. -
Symbolic links are not followed when embedding (Go 1.25 added the
embedfollowsymlinksGODEBUG setting, default0, 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 |
|---|---|
|
Opens a directory and returns a |
|
One-shot: |
|
The directory name given to |
|
An |
|
A nested root for a subdirectory, equally confined. |
The methods mirror the functions of package os, in two waves:
| Since | Methods |
|---|---|
Go 1.24 |
|
Go 1.25 |
|
(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.Symlinkdoes 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
Rootrefers 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.PathErrorwhoseErris a fixed value with the textpath escapes from parent, and whoseOpnames the system call (openathere, ormkdirat, … 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
Source: os.Root and Traversal-resistant file APIs
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
Source: os.Root.FS and os.OpenInRoot
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:
-
Rootdoes not prohibit crossing file-system boundaries, Linux bind mounts,/procspecial 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.ChownandRoot.Chtimescan 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
NULandCOM1. OnGOOS=jsaRootis vulnerable to time-of-check-to-time-of-use attacks and cannot guarantee confinement; onplan9andjsit names a directory and does not track it across renames; onwasip1Root.Chmodis 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.
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
Source: compress/gzip and json.Decoder
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
Source: io.LimitReader and io/fs errors
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 |
|---|---|---|
|
|
Identical. |
|
|
Identical. |
|
|
Identical. |
|
|
Returns |
|
|
Identical. |
|
|
Identical. |
|
|
Identical. |
|
|
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 |
|---|---|
|
Panics ( |
Scanner token longer than the buffer maximum |
|
|
Panics ( |
Write to a closed |
Error |
Method on a nil |
Error |
Write after the reader of an |
|
|
Panics on the next write ( |
Reading a directory with |
Error |
|
Error |
Pitfalls
-
Treating
Read'sn < len(p)as an error or as EOF. Neither is true. Useio.ReadFullfor fixed-size records and loop onRead(orio.Copy,io.ReadAll) otherwise. -
Looking at
errbeforen.Readmay return data andio.EOFtogether; process the bytes first. -
Ignoring
Closeon files opened for writing, and forgettingFlushon abufio.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 thedeferruns per iteration. -
os.ReadFileon a file of unknown size, orio.ReadAllon a network body, allocates as much as the input. Bound it. -
Joining untrusted names with
filepath.Joinand trusting a prefix check. Useos.Root. -
bufio.Scannerwith long lines or binary data: it stops at 64 KiB per token. Raise it withBufferor usebufio.Reader. -
Keeping
Scanner.Bytes()or the slice fromReadSlicepast the next read: the memory is reused. Copy it, or useText()/ReadString. -
os.Exitorlog.Fatalafterdefer: deferred cleanup does not run. Return an exit code fromruninstead. -
Relying on
os.Renameacross file systems or mount points, which fails with a cross-device error. Copy and delete instead. -
Comparing with
os.IsNotExiston a wrapped error. Useerrors.Is(err, fs.ErrNotExist). -
Using
os.Chdirfor convenience in a program with goroutines: it changes the directory for all of them. -
Assuming
0644means0o644. An unprefixed644is decimal. -
Expecting
timeandStatvalues fromembed.FSto 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.Readerandio.Writer,io.Copy,io.Closerand the compositional style correctly; its listings useioutilwhere this page usesosandio(deprecated in Go 1.16), and it predatesio/fs,os.DirFS,embed(all Go 1.16),os.CopyFS(1.23),os.Root(1.24 and 1.25),filepath.IsLocal(1.20) andfilepath.WalkDir(1.16, withSkipAllin 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,bufioandScanner. It was written for Go 1.6 and 1.7: it usesioutil, showsos.IsNotExistwhereerrors.Is(err, fs.ErrNotExist)is the idiom, and predatesio/fs,embedandos.Root. Its examples that ignore the error fromCloseon 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.Fileandio.Writer, and the tool isgo vet. -
Both books describe directory listings as
[]os.FileInfo(throughioutil.ReadDir);os.ReadDirreturns[]fs.DirEntry.
Version notes
| Release | Change |
|---|---|
Go 1.16 |
|
Go 1.20 |
|
Go 1.21 |
|
Go 1.22 |
|
Go 1.23 |
|
Go 1.24 |
|
Go 1.25 |
The rest of the |
Go 1.26 |
|
Go 1.27 |
The release notes list no change to |