The trap in os.Create
You are building a CLI tool that processes a list of users from a CSV file, writes a summary report, and cleans up temporary data before exiting. You need to create a new file for the report, open the CSV to read it, and delete the temp file when you are done. Go makes file operations straightforward with the os package, but the behavior of functions like os.Create hides a trap that catches almost everyone on their first try.
os.Create does not just create a file. If the file already exists, it truncates it to zero length. This means it wipes the contents clean before you write anything. If you pass the name of a file you intended to append to, os.Create will erase it. The compiler will not warn you. The data loss happens at runtime.
File operations in plain words
Think of os.Create as a function that demands a blank slate. It opens the file for writing and ensures the file is empty. os.Open is strictly for reading. It opens the file in read-only mode and returns an error if the file does not exist. If you need more control, such as appending to a file or creating it only if it is missing, you reach for os.OpenFile. This function accepts flags that define exactly how the file should behave.
Go treats files as resources that must be managed explicitly. Every open file consumes a file descriptor, which is an integer the operating system uses to track open files. Operating systems limit the number of open descriptors per process. If you open files without closing them, your program eventually crashes. The standard pattern is to call defer file.Close() immediately after opening the file. This schedules the close call to run when the surrounding function returns, ensuring the resource is released even if an error occurs later.
os.Create truncates. Check the docs before you assume.
Minimal example
Here is the basic pattern for creating, reading, and deleting a file. Notice the error checks and the defer calls.
package main
import (
"fmt"
"os"
)
func main() {
// Create opens the file for writing. If it exists, it truncates to zero length.
file, err := os.Create("report.txt")
if err != nil {
fmt.Println("Failed to create file:", err)
return
}
// Defer ensures the file handle closes when main returns, preventing resource leaks.
defer file.Close()
// Open opens the file for reading. It returns an error if the file is missing.
readFile, err := os.Open("report.txt")
if err != nil {
fmt.Println("Failed to open file:", err)
return
}
// Close the reader as soon as we are done with it, not just at the end of main.
defer readFile.Close()
// Remove deletes the file from the filesystem.
err = os.Remove("report.txt")
if err != nil {
fmt.Println("Failed to remove file:", err)
}
}
How the runtime handles files
When you run this code, os.Create asks the operating system to allocate a file named report.txt. The OS returns a file descriptor. Go wraps this descriptor in a *os.File struct. This struct implements the io.Reader and io.Writer interfaces. This design is powerful. It means you can pass a *os.File to any function that expects an io.Reader, such as json.NewDecoder or bufio.Scanner. You can process data from files, network connections, or string buffers using the same code, as long as the source implements the interface.
The defer file.Close() statement registers a call to Close with the function's cleanup list. When main returns, Go executes the deferred calls in reverse order. This pattern is standard in Go. It keeps the cleanup logic close to the resource acquisition. You will see if err != nil repeated everywhere in Go code. This verbosity is intentional. The language designers chose to make errors explicit values rather than exceptions. This forces the caller to acknowledge the possibility of failure. The community accepts the boilerplate because it makes the unhappy path visible at every call site. You cannot accidentally swallow an error in Go.
Defer close immediately after open. Resource leaks are silent until they crash.
Realistic example: writing with control
Real code usually requires more than just creating and opening. You often need to append to a log file or create a file only if it does not exist. os.OpenFile provides this control through flags.
// WriteLog appends a message to a file and returns an error if it fails.
func WriteLog(filename, message string) error {
// O_CREATE creates the file if it does not exist.
// O_WRONLY opens for writing only.
// O_APPEND ensures writes go to the end of the file.
f, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
if err != nil {
return fmt.Errorf("opening file: %w", err)
}
// Defer close to release the file descriptor when the function returns.
defer f.Close()
// WriteString writes the message to the file.
_, err = f.WriteString(message + "\n")
if err != nil {
return fmt.Errorf("writing message: %w", err)
}
return nil
}
This example uses os.OpenFile with flags combined using the bitwise OR operator |. os.Create is actually a wrapper around os.OpenFile with flags O_WRONLY|O_CREATE|O_TRUNC. Using os.OpenFile directly gives you precision. The mode 0644 sets file permissions. The leading zero is mandatory. It tells the Go compiler to interpret the literal as octal. If you write 644 without the zero, Go treats it as decimal, which results in invalid permissions. The umask setting on the system can modify the permissions you request. If you ask for 0777 but the system umask is 0022, the resulting file might end up with 0755. This is an operating system feature, not a Go behavior. Permissions are requests, not guarantees.
Flags give control. os.Create is just sugar for specific flags.
Temporary files
When you need a scratch file, use os.CreateTemp. This function creates a file in the default temporary directory with a unique name. It returns the file path and the file handle. You are responsible for deleting the file when you are done. Go does not clean up temporary files automatically. If your program crashes, the temp file remains on disk.
// CreateTemp generates a unique filename and opens the file.
f, err := os.CreateTemp("", "prefix-*.txt")
if err != nil {
return err
}
// The path is available via f.Name().
defer os.Remove(f.Name())
defer f.Close()
The first argument is the directory. An empty string uses the system default. The second argument is a pattern. The * is replaced with random characters. This ensures uniqueness. You must call os.Remove on the path returned by f.Name() to clean up.
Temp files stay until you delete them. Go does not clean up after you.
Reading strategies
For small files, os.ReadFile is the simplest option. It reads the entire file into a byte slice in one call. It handles opening, reading, and closing internally. This is safe and concise.
// ReadFile loads the entire file content into memory.
data, err := os.ReadFile("config.json")
if err != nil {
return err
}
// data contains the raw bytes.
Use os.ReadFile when the file fits comfortably in memory. If the file is large, reading it all at once consumes too much RAM. In that case, open the file and use bufio.Scanner or bufio.Reader to process it in chunks. bufio wraps the file handle and provides a buffer. It reduces the number of system calls by reading larger blocks from the OS and serving them to your code. This streaming approach keeps memory usage constant regardless of file size.
ReadFile loads everything. bufio streams chunks. Pick based on size.
Pitfalls and errors
The most common mistake is assuming os.Create appends to an existing file. It does not. It truncates the file to zero length. If you pass the name of a file you want to keep, os.Create will erase its contents before you write anything.
Another trap is forgetting to close the file. If you open a file and return early due to an error without closing it, you leak a file descriptor. Operating systems limit the number of open files per process. If you leak descriptors, your program eventually crashes with an error like too many open files. Always pair Open or Create with a defer Close.
Trying to delete a directory with os.Remove fails. os.Remove only works on files. If you pass a directory path, the function returns an error. The error message usually mentions is a directory. To remove a directory, use os.RemoveAll, which deletes the directory and all its contents recursively. Be careful with RemoveAll. It does not return an error if the path does not exist. It only returns an error if it cannot delete something that is there.
Compiler errors are straightforward here. If you try to pass a string to a function expecting a *os.File, the compiler rejects it with cannot use "filename" (untyped string constant) as *os.File value in argument. If you forget to import os, you get undefined: os. If you try to use a variable that is not declared, the compiler complains with undefined: variable.
os.Remove handles files. os.RemoveAll handles directories. Know the difference.
When to use what
Use os.Create when you need a new file for writing and it is safe to overwrite any existing file with the same name. Use os.Open when you need to read an existing file and want the program to fail if the file is missing. Use os.OpenFile with O_APPEND when you need to add data to the end of a file without destroying previous content. Use os.OpenFile with O_CREATE and O_EXCL when you need to create a file only if it does not already exist, preventing accidental overwrites. Use os.Remove when you need to delete a single file. Use os.RemoveAll when you need to delete a directory and all its contents. Use os.ReadFile when you want to load a small file into memory in a single call. Use os.CreateTemp when you need a unique scratch file for intermediate processing. Use bufio.Scanner when you need to read a large file line by line without loading it all into memory.
Pick the function that matches your intent. The compiler enforces types, but only you can enforce semantics.