Skip to main content
Go intermediate Lesson 18 of 25

File I/O in Go

Read and write files with the os package and bufio, use os.ReadFile vs ioutil, and encode/decode JSON in Go.

Reading Files

Read entire file into memory

Reading a whole file at once is the simplest approach and the right choice for configuration files, templates, and any file small enough to fit comfortably in memory. os.ReadFile (available since Go 1.16, replacing the deprecated ioutil.ReadFile) returns the file’s contents as a byte slice in a single call. If the file does not exist or cannot be read, it returns a descriptive error you can wrap with context.

import "os"

// os.ReadFile reads the entire file into a []byte in one call
data, err := os.ReadFile("data.txt")
if err != nil {
    return fmt.Errorf("reading file: %w", err)
}
fmt.Println(string(data))

Read line by line with bufio.Scanner

For large files — log files, CSV exports, multi-gigabyte datasets — loading everything into memory at once is impractical. bufio.Scanner reads one line at a time, keeping only a single line in memory regardless of the file’s total size. It also handles both Unix (\n) and Windows (\r\n) line endings automatically.

import (
    "bufio"
    "os"
)

func readLines(path string) ([]string, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, fmt.Errorf("open %q: %w", path, err)
    }
    defer f.Close() // always close the file when done

    var lines []string
    scanner := bufio.NewScanner(f)
    for scanner.Scan() {
        lines = append(lines, scanner.Text()) // Text() returns the line without the newline
    }
    return lines, scanner.Err() // scanner.Err() is nil on EOF — check it for real errors
}

For files with very long lines, increase the scanner buffer to avoid “token too long” errors:

scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // allow lines up to 1 MB

Read in chunks with bufio.Reader

When you need finer control than line-by-line — for example, reading binary files or implementing a custom protocol parser — bufio.Reader lets you read up to a delimiter, read a fixed number of bytes, or read character by character, all with an internal buffer that minimises system calls.

reader := bufio.NewReaderSize(f, 64*1024) // 64 KB internal buffer

for {
    line, err := reader.ReadString('\n') // read up to (and including) the next newline
    if len(line) > 0 {
        process(line) // process even a partial line before checking err
    }
    if err == io.EOF {
        break // normal end of file
    }
    if err != nil {
        return err // real read error
    }
}

Writing Files

Write entire content at once

os.WriteFile is the counterpart to os.ReadFile: it writes a byte slice to a file in one call, creating the file if it doesn’t exist or truncating it if it does. The permission bits (e.g. 0644) apply when the file is newly created. For append-only patterns like log files, use os.OpenFile with the O_APPEND flag.

// Write entire content atomically — creates or truncates the file
content := []byte("Hello, Go!\nSecond line\n")
err := os.WriteFile("output.txt", content, 0644) // 0644 = owner rw, group r, other r
if err != nil {
    return err
}

// Append to an existing file — useful for log files
f, err := os.OpenFile("log.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
    return err
}
defer f.Close()
_, err = f.WriteString("new log entry\n")

Buffered writes for many small writes

Writing many small strings one at a time with f.Write generates a system call for each write — this is slow. bufio.Writer batches those writes into its internal buffer and flushes to disk in large chunks. The critical detail: you must call Flush() when done, otherwise data sitting in the buffer never reaches disk.

f, err := os.Create("output.txt")
if err != nil {
    return err
}
defer f.Close()

w := bufio.NewWriter(f)
for i := 0; i < 1000; i++ {
    fmt.Fprintf(w, "line %d\n", i) // writes to the buffer, not to disk yet
}
w.Flush() // flush the buffer to disk — REQUIRED, or data is lost

Working with Paths

Cross-platform path handling is one of the quiet sources of bugs in Go programs. Hard-coding forward slashes breaks on Windows; filepath.Join builds the correct separator for the current OS automatically. Always use filepath functions for filesystem paths, and path (no filepath) only for URL-style paths that are always forward-slash separated.

import (
    "os"
    "path/filepath"
)

// filepath.Join builds the correct separator for the OS
path := filepath.Join("data", "users", "alice.json")
// Windows: data\users\alice.json
// Linux/Mac: data/users/alice.json

// Extract components from a path
dir  := filepath.Dir("/home/alice/file.txt")  // /home/alice
base := filepath.Base("/home/alice/file.txt") // file.txt
ext  := filepath.Ext("/home/alice/file.txt")  // .txt

// Check if a file exists — use os.Stat, not a separate Exists function
info, err := os.Stat("myfile.txt")
if os.IsNotExist(err) {
    fmt.Println("file does not exist")
} else if err == nil {
    fmt.Printf("size: %d bytes, modified: %v\n", info.Size(), info.ModTime())
}

// Walk a directory tree recursively
filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error {
    if err != nil {
        return err // stop walking on permission errors
    }
    if !d.IsDir() && filepath.Ext(path) == ".go" {
        fmt.Println(path)
    }
    return nil
})

// Create a full directory path (like mkdir -p)
os.MkdirAll("data/cache/temp", 0755)

// Create a temporary file — OS cleans it up eventually, but delete it yourself
f, err := os.CreateTemp("", "prefix-*.tmp")
defer os.Remove(f.Name()) // remove when done

JSON Encoding and Decoding

Encode (Marshal)

Go’s encoding/json package uses struct field tags to control JSON key names, omit empty fields, and exclude sensitive data. Struct tags are the idiomatic way to bridge Go’s camelCase/PascalCase naming conventions with JSON’s snake_case conventions. The json:"-" tag completely excludes a field — use it for passwords, tokens, and other data that must never appear in API responses.

import "encoding/json"

type User struct {
    ID        int       `json:"id"`
    Name      string    `json:"name"`
    Email     string    `json:"email,omitempty"` // omitted if empty string
    CreatedAt time.Time `json:"created_at"`
    Password  string    `json:"-"` // never serialised — excluded from all JSON output
}

user := User{ID: 1, Name: "Alice", Email: "[email protected]"}

// Compact JSON — smallest size, for network transmission
data, err := json.Marshal(user)
fmt.Println(string(data))
// {"id":1,"name":"Alice","email":"[email protected]","created_at":"0001-01-01T00:00:00Z"}

// Pretty-printed JSON — indented for human readability or config files
pretty, err := json.MarshalIndent(user, "", "  ")
fmt.Println(string(pretty))
// {
//   "id": 1,
//   "name": "Alice",
//   ...
// }

Decode (Unmarshal)

json.Unmarshal parses a JSON byte slice into a Go value. It matches JSON keys to struct fields case-insensitively and ignores unknown keys by default — making it resilient to API changes. Always check the returned error: malformed JSON, wrong types, and overflowed numbers all surface here.

jsonStr := `{"id": 1, "name": "Alice", "email": "[email protected]"}`

var user User
err := json.Unmarshal([]byte(jsonStr), &user) // pass a pointer so Unmarshal can fill the struct
if err != nil {
    return fmt.Errorf("parsing user: %w", err)
}
fmt.Println(user.Name) // Alice

// Decode into map when the structure is unknown or dynamic
var raw map[string]any
json.Unmarshal([]byte(jsonStr), &raw)
fmt.Println(raw["name"]) // Alice

Streaming JSON (large files)

When working with large JSON datasets, loading everything into memory before encoding or decoding is wasteful. json.NewEncoder and json.NewDecoder operate on io.Writer and io.Reader interfaces directly, so they can process data item by item. This is the right approach for writing JSONL (one JSON object per line) log files or reading API responses that return arrays of thousands of objects.

// Write many records one at a time — no need to build a giant slice first
f, _ := os.Create("records.jsonl")
defer f.Close()

enc := json.NewEncoder(f)
for _, record := range records {
    enc.Encode(record) // writes one JSON object followed by a newline
}

// Read many records one at a time — only one record in memory at once
f, _ = os.Open("records.jsonl")
defer f.Close()

dec := json.NewDecoder(f)
for dec.More() { // More() returns false when there are no more values to decode
    var record Record
    if err := dec.Decode(&record); err != nil {
        return err
    }
    process(record)
}

Practical Example — Config File Read/Write

This example combines file I/O and JSON into a complete config management pattern. Notice the file permission 0600 on write — config files often contain secrets, so owner-only read/write access is appropriate. Wrapping errors with fmt.Errorf and %w at each step makes it clear whether the failure was in reading, parsing, or encoding.

package main

import (
    "encoding/json"
    "fmt"
    "os"
)

type AppConfig struct {
    Database struct {
        Host     string `json:"host"`
        Port     int    `json:"port"`
        Name     string `json:"name"`
    } `json:"database"`
    Server struct {
        Port    int    `json:"port"`
        TLSCert string `json:"tls_cert,omitempty"`
    } `json:"server"`
    Debug bool `json:"debug"`
}

func loadConfig(path string) (*AppConfig, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("loading config: %w", err)
    }

    var cfg AppConfig
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("parsing config: %w", err)
    }
    return &cfg, nil
}

func saveConfig(path string, cfg *AppConfig) error {
    data, err := json.MarshalIndent(cfg, "", "  ") // pretty-print for human readability
    if err != nil {
        return fmt.Errorf("encoding config: %w", err)
    }
    return os.WriteFile(path, data, 0600) // 0600 = owner read/write only — config may contain secrets
}

func main() {
    cfg := &AppConfig{Debug: true}
    cfg.Database.Host = "localhost"
    cfg.Database.Port = 5432
    cfg.Database.Name = "myapp"
    cfg.Server.Port = 8080

    if err := saveConfig("config.json", cfg); err != nil {
        fmt.Println(err)
        return
    }

    loaded, err := loadConfig("config.json")
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Printf("DB: %s:%d/%s\n", loaded.Database.Host, loaded.Database.Port, loaded.Database.Name)
    // DB: localhost:5432/myapp
}

Frequently Asked Questions

What is the difference between os.ReadFile and bufio.Scanner?
os.ReadFile reads the entire file into memory at once — good for small files. bufio.Scanner reads line by line, keeping only one line in memory — good for large files or streams.
Is ioutil.ReadFile still the right way to read files?
No. The ioutil package was deprecated in Go 1.16. Use os.ReadFile, os.WriteFile, and io.ReadAll instead — they are direct replacements.
How do I pretty-print JSON in Go?
Use json.MarshalIndent(v, prefix, indent) instead of json.Marshal. For example: json.MarshalIndent(data, "", " ") produces human-readable JSON with 2-space indentation.