Writing to a file in Go
You just finished parsing a CSV and need to save the results. Or your web server crashed and you want to dump the panic trace to disk. Or you're writing a CLI tool that generates a report. In all these cases, you need to get bytes out of your program and onto the filesystem. Go makes this straightforward, but the standard library gives you a few different doors. Picking the right one depends on whether you're overwriting, appending, or streaming gigabytes of data.
The three-step dance
Writing to a file is always a three-step process. First, you ask the operating system for a handle to a file. The OS checks permissions, creates the file if needed, and hands you a file descriptor. Second, you push data through that handle. The data might sit in a kernel buffer for a moment before hitting the disk. Third, you close the handle. Closing flushes any remaining buffers and tells the OS you're done.
If you skip the close, you risk losing data or running out of file descriptors. The OS has a limited pool of descriptors per process. Leak enough of them and your program starts failing with too many open files.
Think of a file handle like a pen borrowed from the OS. You write on the paper. When you're done, you hand the pen back. If you keep the pen forever, the OS runs out of pens for other programs. If you drop the pen without capping it, the last few words might not dry.
Close the handle or leak the resource.
Minimal example: create and write
Here's the simplest way to write a file: create it, write a line, close it.
package main
import (
"os"
)
func main() {
// os.Create truncates the file if it exists, or creates it with 0666 permissions.
// It returns a *os.File which implements io.Writer.
file, err := os.Create("output.txt")
if err != nil {
// Handle error immediately. Returning early prevents partial writes.
panic(err)
}
// defer ensures Close runs when main returns, even if a panic occurs later.
// This is the standard pattern to prevent resource leaks.
defer file.Close()
// WriteString writes the raw string to the file.
// It returns the number of bytes written and an error.
n, err := file.WriteString("Hello, filesystem!\n")
if err != nil {
panic(err)
}
// n is useful for debugging or progress tracking, but often discarded.
_ = n
}
if err != nil is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Don't swallow errors. If os.Create fails, your program should stop or log the failure.
defer file.Close() lives right after the open. The closer the defer is to the resource acquisition, the less likely you'll forget it or return early without closing. This is a Go idiom. Trust it.
If you forget to capture the error from os.Create, the compiler rejects the program with declared and not used. Go forces you to handle the error or explicitly discard it with _.
defer lives next to open. Errors get checked immediately.
What happens under the hood
When you call os.Create, Go translates that to a system call. On Linux, this is usually open with flags like O_WRONLY | O_CREAT | O_TRUNC. The kernel allocates an inode and a file descriptor. Go wraps that descriptor in a *os.File struct.
When you call WriteString, Go might use a small internal buffer. The data goes to the kernel buffer. The kernel schedules the write to the disk. This is asynchronous from the CPU's perspective. The write returns before the electrons actually settle on the platter or flash cells.
Close releases the descriptor and flushes user-space buffers. It does not guarantee the data hits the disk. The data might still be in the OS page cache. If the power cuts immediately after Close, you could lose data. For critical data, you need file.Sync(). Sync calls fsync, which forces the kernel to write the data to the storage device.
Close flushes buffers. Sync hits the disk. Know the difference.
Realistic control: flags and permissions
Production code rarely uses os.Create because it overwrites everything. You usually want to append, or you need specific permissions. os.OpenFile gives you the flags.
package main
import (
"os"
)
func appendLog(path string, message string) error {
// O_APPEND ensures writes go to the end of the file.
// O_CREATE makes the file if it doesn't exist.
// O_WRONLY opens for writing only.
// Permissions 0644 allow owner read/write, group/others read.
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
// Close immediately in defer.
defer f.Close()
// Write the message.
_, err = f.WriteString(message + "\n")
return err
}
The permission mode 0644 is octal. The leading zero tells Go to interpret the number as base 8. 0644 means the owner can read and write, while the group and others can only read. os.Create uses 0666, which is masked by the user's umask. os.OpenFile lets you set the mode explicitly, ignoring the umask for the creation step.
Flags control behavior. Permissions control access. OpenFile gives you both.
Performance: buffering large writes
Writing millions of small lines with os.File can be slow. Each WriteString might trigger a system call. System calls have overhead. bufio.NewWriter batches writes in memory, reducing the number of system calls.
package main
import (
"bufio"
"os"
"strconv"
)
func writeManyLines(path string, count int) error {
f, err := os.Create(path)
if err != nil {
return err
}
// Close the file handle.
defer f.Close()
// bufio.NewWriter wraps the file with a buffer.
// Writes go to memory first, reducing system calls.
w := bufio.NewWriter(f)
// Flush must happen before Close to ensure buffered data is written.
defer w.Flush()
// Write many lines efficiently.
for i := 0; i < count; i++ {
// WriteString returns bytes written and error.
// We discard the count and check error.
_, err := w.WriteString("Line " + strconv.Itoa(i) + "\n")
if err != nil {
return err
}
}
return nil
}
bufio introduces a contract: you must call Flush. If you panic before Flush, the buffered data is lost. defer w.Flush() is essential. Note the defer order: defer f.Close() then defer w.Flush(). Defers run in reverse order. Flush runs first, writing data to the file, then Close runs, releasing the descriptor. If you reversed the defers, Close would run first, and Flush would try to write to a closed file, causing a panic.
Defer order matters. Flush before close.
Robustness: atomic writes
When you overwrite a file, a crash can leave it half-written. If another process reads the file during the write, it sees corrupt data. Atomic writes prevent this. You write to a temporary file, sync it, close it, and rename it. Rename is atomic on POSIX systems. The target file is replaced instantly.
package main
import (
"os"
)
func atomicWrite(path string, data []byte) error {
// CreateTemp generates a unique filename in the directory.
// It opens the file for writing and returns the handle.
tmp, err := os.CreateTemp("", "config-*.tmp")
if err != nil {
return err
}
// Remove the temp file on failure to avoid clutter.
// This defer runs after Close due to LIFO order.
defer os.Remove(tmp.Name())
// Write data to the temp file.
_, err = tmp.Write(data)
if err != nil {
tmp.Close()
return err
}
// Sync forces data to disk before closing.
// This prevents corruption if the system crashes.
err = tmp.Sync()
if err != nil {
tmp.Close()
return err
}
// Close releases the file descriptor.
err = tmp.Close()
if err != nil {
return err
}
// Rename is atomic on POSIX systems.
// The target file is replaced instantly.
return os.Rename(tmp.Name(), path)
}
defer os.Remove(tmp.Name()) cleans up the temp file if the function returns early due to an error. If Rename succeeds, the temp file is moved to the target path. The Remove call will fail because the temp name no longer exists. This is harmless. The error from Remove is ignored by the defer. In production code, you might wrap the remove in a function that ignores os.ErrNotExist.
Atomic writes protect against corruption. Use them for config files and databases.
Pitfalls and errors
If you try to write to a file opened with O_RDONLY, the compiler won't stop you, but the runtime will return an error. You'll see bad file descriptor or file already closed if you double-close.
The compiler complains with declared and not used if you assign file, err := os.Create(...) and never use err. You must check the error or write _, err := ... and handle err.
os.Create truncates the file. If you pass the wrong path, you lose data. Always verify the path before creating.
Close does not sync. If you need durability, call Sync.
The compiler catches unused errors. The runtime catches bad descriptors. Handle both.
Decision matrix
Use os.WriteFile when you have a small byte slice and want a one-liner that handles open, write, sync, and close automatically.
Use os.Create when you need to overwrite a file completely and don't care about preserving previous content.
Use os.OpenFile with O_APPEND when you are logging events or accumulating data over time.
Use os.OpenFile with explicit permissions when security matters and you need to control who can read or write the file.
Use bufio.NewWriter when you are writing millions of small lines and need to reduce system call overhead.
Use file.Sync() after writing when data integrity is paramount and you cannot afford to lose the last few kilobytes on a power failure.
Use a temp file plus os.Rename when you need atomic updates to prevent corruption during overwrites.
Simple writes use WriteFile. Complex writes use OpenFile. Buffered writes use bufio.