Files, streams and the io interfaces
Two interfaces carry most of Go's I/O, and you met them in chapter ten:.
Two interfaces carry most of Go's I/O, and you met them in chapter ten:
type Reader interface { Read(p []byte) (n int, err error) }type Writer interface { Write(p []byte) (n int, err error) }
One method each. Files, network connections, HTTP bodies, gzip streams, in-memory buffers, standard input and standard output all satisfy them, which means a function written against io.Reader works with every one of those without knowing which it has.
Reading a whole file
When the file is small and you want it all:
data, err := os.ReadFile("config.json") // []byteif err != nil {return fmt.Errorf("reading config: %w", err)}text := string(data)
And writing:
err := os.WriteFile("out.txt", []byte("hello"), 0o644)
0o644 is the Unix permission mode: owner read and write, everyone else read. On Windows it is mostly ignored. 0o600 for anything secret.
Watch out
os.ReadFile loads the entire file into memory. For a 2GB log file that is 2GB of RAM, and your process is killed. Use it for configuration and small documents; stream anything that could be large, which is the rest of this chapter.
Streaming line by line
f, err := os.Open("access.log")if err != nil {return err}defer f.Close()scanner := bufio.NewScanner(f)for scanner.Scan() {line := scanner.Text()// ...}if err := scanner.Err(); err != nil {return fmt.Errorf("reading access.log: %w", err)}
Memory stays constant regardless of file size.
Watch out
*You must check scanner.Err().* The loop ends identically whether the file finished or the disk failed, and without the check a read error looks exactly like a successful end. This is the most commonly skipped error check in Go.
Second trap: Scanner refuses lines longer than 64KB by default, returningbufio.Scanner: token too long. For long lines, enlarge the buffer:
scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024) // 1MB lines
os.Open against os.Create
os.Open- Read only. Fails if the file is missing
os.Create- Write only. Creates, or truncates an existing file to empty
os.OpenFile- Everything else: append, read-write, custom permissions
os.Create truncating is worth remembering, because it means opening a file to "check something" with Create destroys it. To append:
f, err := os.OpenFile("app.log",os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
Buffer your writes
Every Write on a file is a system call. Writing a million lines one at a time is a million system calls.
f, err := os.Create("out.txt")if err != nil {return err}defer f.Close()w := bufio.NewWriter(f)defer w.Flush() // without this, you lose the tail of the filefor _, line := range lines {fmt.Fprintln(w, line)}
Watch out
A buffered writer must be flushed. The last few kilobytes sit in memory until you do. defer w.Flush() immediately after creating it is the habit, and note the order: defer f.Close() was registered first so it runs last, which is correct, because flushing into a closed file fails.
Better still, check the flush error, because a failed flush means data did not reach the disk:
defer func() {if ferr := w.Flush(); ferr != nil && err == nil {err = fmt.Errorf("flushing: %w", ferr)}}()
io.Copy, and the pattern it enables
io.Copy(dst, src) // stream everything from src to dst
That one line downloads a file to disk, with constant memory:
resp, err := http.Get(url)if err != nil {return err}defer resp.Body.Close()f, err := os.Create("download.bin")if err != nil {return err}defer f.Close()written, err := io.Copy(f, resp.Body)
An HTTP response body is an io.Reader. A file is an io.Writer. Neither knows about the other.
Useful companions:
io.Discard // a Writer that throws everything away// read at most 1MB: use on untrusted inputio.LimitReader(r, 1<<20)io.MultiWriter(f, os.Stdout) // write to both at once, like teeio.TeeReader(r, logFile) // read from r while copying to logFile
Watch out
io.LimitReader on anything a user controls is a real defence. Without it, a request body or an uploaded file can be as large as the client wants, and reading it into memory is a denial of service that costs the attacker nothing. For HTTP specifically there is http.MaxBytesReader, which also returns a proper error.
Writing testable code against interfaces
This is the practical payoff. A function that takes concrete types is hard to test:
// Hard: needs real files on disk.func Process(inPath, outPath string) error
The same function over interfaces is trivial to test:
func Process(r io.Reader, w io.Writer) error {scanner := bufio.NewScanner(r)for scanner.Scan() {fmt.Fprintln(w, strings.ToUpper(scanner.Text()))}return scanner.Err()}
func TestProcess(t *testing.T) {in := strings.NewReader("hello\nworld\n")var out bytes.Bufferif err := Process(in, &out); err != nil {t.Fatal(err)}if got, want := out.String(), "HELLO\nWORLD\n"; got != want {t.Errorf("got %q, want %q", got, want)}}
No temp files, no cleanup, no filesystem. strings.NewReader andbytes.Buffer are the two types you will use most in tests.
If you know JavaScript
This is why Node's stream API exists, and Go's version is far smaller: one method per direction instead of an event emitter with data, end, errorand backpressure semantics you have to learn. The tradeoff is that Go's streams are synchronous and blocking, which is fine because blocking a goroutine is cheap.
Paths, portably
import "path/filepath"// data/users/ada.json or data\users\ada.jsonfilepath.Join("data", "users", "ada.json")filepath.Ext("report.pdf") // ".pdf"filepath.Base("/tmp/report.pdf") // "report.pdf"filepath.Dir("/tmp/report.pdf") // "/tmp"
Use filepath, not path, and never build a path with string concatenation.path is for URLs and always uses forward slashes; filepath adapts to the operating system.
Watch out
Never join a user-supplied name onto a directory without cleaning it:
// Directory traversal: "../../etc/passwd" escapes the directory.f, _ := os.Open(filepath.Join("uploads", userInput))
filepath.Join does call Clean, which resolves the .., but the result can still point outside uploads. The check is explicit:
full := filepath.Join(base, filepath.Clean("/"+userInput))if !strings.HasPrefix(full, base+string(os.PathSeparator)) {return errors.New("invalid path")}
Go 1.24 added os.Root for this, which opens a directory and refuses to escape it, and is the better answer where available.
Temporary files and directories
dir, err := os.MkdirTemp("", "myapp-*")if err != nil {return err}defer os.RemoveAll(dir)f, err := os.CreateTemp(dir, "upload-*.tmp")
In tests, t.TempDir() does this and cleans up automatically:
func TestWrite(t *testing.T) {dir := t.TempDir() // removed when the test ends, pass or failpath := filepath.Join(dir, "out.txt")...}
Project 19: a log analyser
Bring together everything from Parts I and II: stream files that may be large, parse them, aggregate with maps, sort, and process several files concurrently.
package mainimport ("bufio""flag""fmt""io""os""slices""strconv""strings""sync")// A line looks like:// 192.168.1.1 GET /api/users 200 134mstype stats struct {requests intbyStatus map[int]intbyPath map[string]inttotalMS intslowest stringsloweste int}func newStats() *stats {return &stats{byStatus: map[int]int{}, byPath: map[string]int{}}}// analyse takes an io.Reader, not a filename, so it is testable with a// strings.Reader and works on stdin, a file or a network stream alike.func analyse(r io.Reader) (*stats, error) {s := newStats()sc := bufio.NewScanner(r)sc.Buffer(make([]byte, 0, 256*1024), 256*1024)for sc.Scan() {f := strings.Fields(sc.Text())if len(f) < 5 {// skip malformed lines rather than failingcontinue}status, err := strconv.Atoi(f[3])if err != nil {continue}ms, err := strconv.Atoi(strings.TrimSuffix(f[4], "ms"))if err != nil {continue}s.requests++s.byStatus[status]++s.byPath[f[2]]++s.totalMS += msif ms > s.sloweste {s.sloweste, s.slowest = ms, f[2]}}// The check that is always skipped and always matters.if err := sc.Err(); err != nil {return nil, fmt.Errorf("scanning: %w", err)}return s, nil}func (s *stats) merge(o *stats) {s.requests += o.requestss.totalMS += o.totalMSfor k, v := range o.byStatus {s.byStatus[k] += v}for k, v := range o.byPath {s.byPath[k] += v}if o.sloweste > s.sloweste {s.sloweste, s.slowest = o.sloweste, o.slowest}}func main() {top := flag.Int("top", 5, "how many paths to show")flag.Parse()paths := flag.Args()if len(paths) == 0 {// No files given: read standard input, so the tool pipes.s, err := analyse(os.Stdin)if err != nil {fail(err)}report(s, *top)return}// One goroutine per file, each with its own stats to avoid any// shared// state, merged at the end under a mutex.total := newStats()var mu sync.Mutexvar wg sync.WaitGroupvar failed intfor _, p := range paths {wg.Go(func() {f, err := os.Open(p)if err != nil {mu.Lock()failed++mu.Unlock()fmt.Fprintf(os.Stderr, "skipping %s: %v\n", p, err)return}defer f.Close()s, err := analyse(f)if err != nil {fmt.Fprintf(os.Stderr, "reading %s: %v\n", p, err)return}mu.Lock()total.merge(s)mu.Unlock()})}wg.Wait()report(total, *top)if failed > 0 {os.Exit(1)}}func report(s *stats, top int) {if s.requests == 0 {fmt.Println("no requests found")return}fmt.Printf("requests %d\n", s.requests)fmt.Printf("average %dms\n", s.totalMS/s.requests)fmt.Printf("slowest %s (%dms)\n\n", s.slowest, s.sloweste)codes := make([]int, 0, len(s.byStatus))for c := range s.byStatus {codes = append(codes, c)}slices.Sort(codes)for _, c := range codes {fmt.Printf(" %d %d\n", c, s.byStatus[c])}type pair struct {path stringn int}paths := make([]pair, 0, len(s.byPath))for p, n := range s.byPath {paths = append(paths, pair{p, n})}slices.SortFunc(paths, func(a, b pair) int { return b.n - a.n })fmt.Println()for _, p := range paths[:min(top, len(paths))] {fmt.Printf(" %-30s %d\n", p.path, p.n)}}func fail(err error) {fmt.Fprintln(os.Stderr, "loganalyse:", err)os.Exit(1)}
Make a test file and run it:
cat > access.log <<'EOF'1.1.1.1 GET /api/users 200 134ms1.1.1.2 GET /api/users 200 89ms1.1.1.3 POST /api/login 401 12ms1.1.1.4 GET /api/reports 200 2400msEOFgo run . access.logcat access.log | go run .
Three design choices worth noticing. analyse takes an io.Reader, so the same function serves a file, standard input, and a strings.Reader in a test: write that test, it is four lines. Each goroutine builds its ownstats and merges once at the end, so the lock is held briefly rather than on every line. And malformed lines are skipped rather than fatal, because a log analyser that dies on one bad line is useless against real logs.
Your turn
Write the test for analyse using strings.NewReader, covering a good line, a malformed line and an empty input. Then add a -json flag that emits the stats as JSON instead of text, which needs the capital-letter rule and struct tags from the last chapter. Finally, feed it a file with a 100KB single line and watch where the buffer limit bites if you remove the sc.Buffer call.
Assignment
Commit your work with the message Files - Assignment One, exactly as written, so the leaderboard can score it.
Try it
Write wc, then make it testable without touching the disk.
In part3-standard-library/20-io:
One. Count lines, words and bytes in a file with bufio.Scanner. Handle the scanner's error, which is the line everybody forgets.
Two. Change the signature to take an io.Reader rather than a filename, and have main pass the file. Now the test passes a strings.Reader and there are no fixtures.
Three. Add a mode that copies input to output with io.Copy and reports the bytes moved, with the writes buffered and the buffer flushed in adefer.
Four. Feed it a file with a very long single line and fix the scanner error you get. Record the fix in NOTES.md.
Spot a typo? Have an idea?
Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled, suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.
Suggest an improvement on GitHub