File Operations in Go: Read, Write, Append, Delete, Binary & Large File Chunking
Go's standard library (os, io, bufio, io/ioutil in older versions) gives you everything you need to work with files — from simple one-line reads to streaming gigabyte-sized files in fixed-size chunks. This guide walks through all of it, from beginner to advanced.
Table of Contents
- Setup
- Reading Files
- Writing Files
- Appending to Files
- Deleting Files
- Binary File Operations
- Large File Batch/Chunk Processing
- Copying Files Efficiently
- Best Practices & Common Pitfalls
Setup
No external packages are needed — everything shown here comes from Go's standard library:
import (
"bufio"
"fmt"
"io"
"os"
)
Create a working folder and initialize a module if you want to run the examples:
mkdir go-file-demo && cd go-file-demo
go mod init go-file-demo
Reading Files
1. Read entire file at once (simplest way)
Best for small-to-medium text or config files.
package main
import (
"fmt"
"os"
)
func main() {
data, err := os.ReadFile("notes.txt")
if err != nil {
fmt.Println("Error reading file:", err)
return
}
fmt.Println(string(data))
}
os.ReadFileloads the whole file into memory. Avoid it for very large files (see Large File Batch/Chunk Processing).
2. Read line-by-line with bufio.Scanner
Ideal for logs, CSVs, or any line-oriented text file.
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
file, err := os.Open("notes.txt")
if err != nil {
fmt.Println("Error opening file:", err)
return
}
defer file.Close()
scanner := bufio.NewScanner(file)
lineNum := 1
for scanner.Scan() {
fmt.Printf("%d: %s\n", lineNum, scanner.Text())
lineNum++
}
if err := scanner.Err(); err != nil {
fmt.Println("Scanner error:", err)
}
}
By default bufio.Scanner has a max token size (~64KB per line). For very long lines, increase the buffer:
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024) // up to 10MB per line
3. Read with bufio.Reader (fine-grained control)
Useful when you need to read by delimiter or custom chunk size instead of by line.
package main
import (
"bufio"
"fmt"
"io"
"os"
)
func main() {
file, err := os.Open("notes.txt")
if err != nil {
fmt.Println(err)
return
}
defer file.Close()
reader := bufio.NewReader(file)
for {
line, err := reader.ReadString('\n')
fmt.Print(line)
if err == io.EOF {
break
}
if err != nil {
fmt.Println("Read error:", err)
break
}
}
}
Writing Files
1. Write entire content at once
Creates the file if it doesn't exist, truncates it if it does.
package main
import (
"fmt"
"os"
)
func main() {
content := []byte("Hello from Go!\nThis is line two.\n")
err := os.WriteFile("output.txt", content, 0644)
if err != nil {
fmt.Println("Error writing file:", err)
return
}
fmt.Println("File written successfully")
}
0644 is the Unix file permission (owner read/write, others read-only).
2. Write using os.Create + bufio.Writer (better for large/streamed writes)
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
file, err := os.Create("output.txt")
if err != nil {
fmt.Println("Error creating file:", err)
return
}
defer file.Close()
writer := bufio.NewWriter(file)
defer writer.Flush() // IMPORTANT: flush before the function returns
lines := []string{"Line 1", "Line 2", "Line 3"}
for _, line := range lines {
_, err := writer.WriteString(line + "\n")
if err != nil {
fmt.Println("Write error:", err)
return
}
}
}
bufio.Writerbuffers data in memory and writes to disk in batches, which is much faster than callingfile.Writerepeatedly. Always callFlush(), or your final buffered bytes may never reach disk.
Appending to Files
Use os.OpenFile with the O_APPEND flag combined with O_CREATE (create if missing) and O_WRONLY.
package main
import (
"fmt"
"os"
)
func main() {
file, err := os.OpenFile("log.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
fmt.Println("Error opening file:", err)
return
}
defer file.Close()
_, err = file.WriteString("New log entry\n")
if err != nil {
fmt.Println("Error appending to file:", err)
return
}
fmt.Println("Appended successfully")
}
Common os.OpenFile flags
| Flag | Meaning |
|---|---|
os.O_RDONLY |
Read-only |
os.O_WRONLY |
Write-only |
os.O_RDWR |
Read and write |
os.O_APPEND |
Append to file instead of overwriting |
os.O_CREATE |
Create file if it doesn't exist |
os.O_TRUNC |
Truncate file to zero length when opened |
os.O_EXCL |
Used with O_CREATE, fails if file exists (useful for atomic "create-only" semantics) |
You can combine flags with | as shown above.
Deleting Files
1. Delete a single file
package main
import (
"fmt"
"os"
)
func main() {
err := os.Remove("output.txt")
if err != nil {
fmt.Println("Error deleting file:", err)
return
}
fmt.Println("File deleted successfully")
}
2. Delete a directory and everything inside it
err := os.RemoveAll("temp_folder")
if err != nil {
fmt.Println("Error deleting folder:", err)
}
os.Removefails on non-empty directories. Useos.RemoveAllwhen you need recursive deletion — but be careful, it does not ask for confirmation.
3. Safe delete: check existence first
package main
import (
"errors"
"fmt"
"os"
)
func main() {
path := "maybe.txt"
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
fmt.Println("File does not exist, nothing to delete")
return
}
if err := os.Remove(path); err != nil {
fmt.Println("Error deleting file:", err)
return
}
fmt.Println("Deleted:", path)
}
Binary File Operations
Go treats all files as byte streams — "binary" just means you're not treating the content as text. This is the same API, just applied to []byte without string conversions, and often combined with encoding/binary for structured binary data.
1. Read/write raw binary data
package main
import (
"fmt"
"os"
)
func main() {
// Write raw bytes (e.g., an image, a serialized struct, etc.)
data := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} // PNG header bytes
err := os.WriteFile("sample.bin", data, 0644)
if err != nil {
fmt.Println("Write error:", err)
return
}
// Read it back
readBack, err := os.ReadFile("sample.bin")
if err != nil {
fmt.Println("Read error:", err)
return
}
fmt.Printf("Bytes: % X\n", readBack)
}
2. Copy a binary file (e.g., image, PDF, zip) without corruption
Never use string-based reads for binary files — always use []byte and avoid text-mode functions like bufio.Scanner/ReadString, which can mangle data at newline-like byte sequences.
package main
import (
"fmt"
"io"
"os"
)
func copyBinaryFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in) // handles binary data safely
return err
}
func main() {
if err := copyBinaryFile("photo.jpg", "photo_copy.jpg"); err != nil {
fmt.Println("Copy failed:", err)
return
}
fmt.Println("Binary file copied successfully")
}
3. Reading fixed-size binary structures with encoding/binary
Useful for custom binary formats, protocol parsing, or reading structured records.
package main
import (
"encoding/binary"
"fmt"
"os"
)
type Record struct {
ID uint32
Score float64
}
func main() {
file, err := os.Open("records.bin")
if err != nil {
fmt.Println(err)
return
}
defer file.Close()
var rec Record
for {
err := binary.Read(file, binary.LittleEndian, &rec)
if err != nil {
break // likely io.EOF
}
fmt.Printf("ID=%d Score=%.2f\n", rec.ID, rec.Score)
}
}
To write records in the same format, use binary.Write(file, binary.LittleEndian, &rec).
4. Get file info before working with it (size, mode, mod time)
info, err := os.Stat("sample.bin")
if err != nil {
fmt.Println(err)
return
}
fmt.Println("Name:", info.Name())
fmt.Println("Size (bytes):", info.Size())
fmt.Println("Permissions:", info.Mode())
fmt.Println("Modified:", info.ModTime())
Large File Batch/Chunk Processing
When files are too large to load into memory (multi-GB logs, video files, database dumps), read and process them in fixed-size chunks using a reusable buffer.
1. Basic chunked reading
package main
import (
"fmt"
"io"
"os"
)
const chunkSize = 4096 // 4KB per chunk — tune based on your use case
func main() {
file, err := os.Open("largefile.bin")
if err != nil {
fmt.Println("Error opening file:", err)
return
}
defer file.Close()
buffer := make([]byte, chunkSize)
totalBytes := 0
for {
bytesRead, err := file.Read(buffer)
if bytesRead > 0 {
// process buffer[:bytesRead] here
totalBytes += bytesRead
}
if err == io.EOF {
break
}
if err != nil {
fmt.Println("Read error:", err)
return
}
}
fmt.Println("Total bytes processed:", totalBytes)
}
2. Chunked read + chunked write (streaming transform)
A common real-world pattern: read a large file in chunks, transform each chunk (e.g., encrypt, compress, hash), and write the result to another file — without ever holding the whole file in memory.
package main
import (
"fmt"
"io"
"os"
)
const chunkSize = 1024 * 1024 // 1MB chunks
func processInChunks(srcPath, dstPath string) error {
src, err := os.Open(srcPath)
if err != nil {
return err
}
defer src.Close()
dst, err := os.Create(dstPath)
if err != nil {
return err
}
defer dst.Close()
buffer := make([]byte, chunkSize)
for {
n, err := src.Read(buffer)
if n > 0 {
chunk := buffer[:n]
// Example transform: uppercase-ish byte manipulation, hashing,
// encryption, compression — replace with your own logic
transformed := transformChunk(chunk)
if _, werr := dst.Write(transformed); werr != nil {
return werr
}
}
if err == io.EOF {
break
}
if err != nil {
return err
}
}
return nil
}
func transformChunk(data []byte) []byte {
// Placeholder: identity transform. Swap this for real processing.
return data
}
func main() {
if err := processInChunks("largefile.bin", "processed_output.bin"); err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("Large file processed in chunks successfully")
}
3. Batch-processing many large files (worker pool pattern)
When you have thousands of large files to process (e.g., a folder of uploaded documents), combine chunked reading with a goroutine worker pool so multiple files are processed concurrently without exhausting memory.
package main
import (
"fmt"
"io"
"os"
"path/filepath"
"sync"
)
const (
chunkSize = 512 * 1024 // 512KB
numWorkers = 4
)
func processFile(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
buffer := make([]byte, chunkSize)
for {
n, err := file.Read(buffer)
if n > 0 {
// process buffer[:n]
_ = buffer[:n]
}
if err == io.EOF {
break
}
if err != nil {
return err
}
}
return nil
}
func worker(id int, jobs <-chan string, wg *sync.WaitGroup) {
defer wg.Done()
for path := range jobs {
if err := processFile(path); err != nil {
fmt.Printf("[worker %d] error processing %s: %v\n", id, path, err)
continue
}
fmt.Printf("[worker %d] finished %s\n", id, path)
}
}
func main() {
dir := "large_files_folder"
entries, err := os.ReadDir(dir)
if err != nil {
fmt.Println("Error reading directory:", err)
return
}
jobs := make(chan string, len(entries))
var wg sync.WaitGroup
for i := 1; i <= numWorkers; i++ {
wg.Add(1)
go worker(i, jobs, &wg)
}
for _, entry := range entries {
if !entry.IsDir() {
jobs <- filepath.Join(dir, entry.Name())
}
}
close(jobs)
wg.Wait()
fmt.Println("All files processed")
}
This pattern is a good starting point for building something like a bulk file-processing service (e.g., resizing images, scanning uploads, or transcoding — comparable in spirit to a chunked-upload/CDN pipeline).
4. Using bufio.Reader for chunked reads with buffering benefits
bufio.Reader reduces the number of actual syscalls by maintaining its own internal buffer on top of your read loop — useful when your chunk size is small and you want fewer disk reads.
package main
import (
"bufio"
"fmt"
"io"
"os"
)
func main() {
file, err := os.Open("largefile.bin")
if err != nil {
fmt.Println(err)
return
}
defer file.Close()
reader := bufio.NewReaderSize(file, 64*1024) // 64KB internal buffer
chunk := make([]byte, 8*1024) // 8KB read chunks
for {
n, err := reader.Read(chunk)
if n > 0 {
// process chunk[:n]
}
if err == io.EOF {
break
}
if err != nil {
fmt.Println("Read error:", err)
return
}
}
fmt.Println("Done reading large file with buffered chunks")
}
Copying Files Efficiently
For most copy needs — small or large, text or binary — io.Copy is the right tool since it streams internally and never loads the full file into memory:
func copyFile(src, dst string) (int64, error) {
in, err := os.Open(src)
if err != nil {
return 0, err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return 0, err
}
defer out.Close()
return io.Copy(out, in)
}
If you need control over the internal buffer size (e.g., for performance tuning on very large files), use io.CopyBuffer:
buf := make([]byte, 1024*1024) // 1MB buffer
written, err := io.CopyBuffer(out, in, buf)
Best Practices & Common Pitfalls
- Always check and handle errors. File operations fail for many reasons (permissions, missing paths, disk full) — never ignore the
errorreturn value. - Always
defer file.Close()right after a successfulOpen/Createcall, so the file descriptor is released even if the function returns early. - Always
Flush()abufio.Writerbefore the program exits or the file is closed — buffered data not flushed is silently lost. - Don't use
os.ReadFile/os.WriteFilefor huge files. They load everything into memory. Use chunked reads/writes (bufio,io.Copy, manual buffers) instead. - Use
io.Copy/io.CopyBufferfor binary data, never string-based scanning, since binary content can contain byte sequences that look like line breaks or other delimiters. - Pick a sensible chunk size. Too small (e.g., 512 bytes) causes excessive syscalls; too large wastes memory. 32KB–1MB is a common sweet spot, adjust based on profiling.
- Use
os.O_EXCLwithos.O_CREATEif you need atomic "create only if it doesn't exist" semantics (avoids race conditions in concurrent environments). - Watch permissions.
0644is common for regular files,0600if only the owner should read/write (e.g., secrets, private keys). - Use
errors.Is(err, os.ErrNotExist)(oros.ErrExist) instead of comparing error strings — it's the idiomatic, version-safe way to check specific file errors. - For concurrent batch processing, bound your worker pool. Opening thousands of files simultaneously can exhaust OS file descriptor limits — a worker pool with a fixed number of goroutines avoids this.
Quick Reference Table
| Task | Recommended Function(s) |
|---|---|
| Read whole small file | os.ReadFile |
| Read line by line | bufio.NewScanner |
| Read with custom control | bufio.NewReader + ReadString/Read |
| Write whole file (overwrite) | os.WriteFile |
| Write with buffering | os.Create + bufio.NewWriter |
| Append to a file | os.OpenFile with O_APPEND|O_CREATE|O_WRONLY |
| Delete a file | os.Remove |
| Delete a folder recursively | os.RemoveAll |
| Binary read/write | os.ReadFile/os.WriteFile, encoding/binary |
| Copy any file (safe for binary) | io.Copy / io.CopyBuffer |
| Process large file in chunks | Manual buffer loop with file.Read(buffer) |
| Batch-process many large files | Worker pool (goroutines + channels) + chunked reads |
With these patterns you can handle everything from a tiny config file to gigabyte-scale batch pipelines — reading, writing, appending, deleting, and safely handling binary data — all using Go's standard library, no third-party dependencies required.