The scratchpad problem
You are building a command line tool that generates a report. The report takes three seconds to compute. You want to save it to disk so the user can open it in their browser, but you do not want to leave a file named report_final_v2.csv cluttering their home directory. Or maybe you are writing a web handler that needs to stage an uploaded image before passing it to an external processor. In both cases, you need a scratchpad on disk that appears, does its job, and vanishes without a trace.
How temporary files work in Go
Temporary files are like sticky notes on a shared whiteboard. The operating system provides a designated area for short lived data. On Linux and macOS, that area is usually /tmp. On Windows, it lives under %TEMP%. The OS cleans up old files automatically, but relying on the filesystem garbage collector is a bad habit. Your program should clean up after itself. Go gives you a single function to grab a blank note, write on it, and toss it in the trash when you are done. The function is os.CreateTemp.
The standard library handles the heavy lifting. It resolves the correct system directory, generates a collision free name, opens the file with restrictive permissions, and hands you a ready to use handle. You do not need to query environment variables or implement your own random suffix generator. You just ask for a temp file and get one.
A minimal, safe pattern
Here is the baseline lifecycle. Create the file, schedule cleanup, write your data, close the handle.
package main
import (
"fmt"
"os"
)
// main demonstrates the standard temporary file lifecycle.
func main() {
// Empty dir lets the OS pick the default temp directory.
// The pattern must contain a wildcard to force unique suffix generation.
f, err := os.CreateTemp("", "scratch-*")
if err != nil {
fmt.Println(err)
return
}
// Schedule deletion to run when the function scope ends.
defer os.Remove(f.Name())
// Write directly to the open file handle.
_, err = f.WriteString("temporary payload\n")
if err != nil {
fmt.Println(err)
return
}
// Close flushes buffers and releases the OS file descriptor.
err = f.Close()
if err != nil {
fmt.Println(err)
return
}
}
What happens under the hood
The call to os.CreateTemp performs three atomic operations. It generates a random suffix using a secure random number generator, opens the file with exclusive access, and returns a ready to use *os.File handle. The pattern argument requires a * character. If you pass "scratch-" without the wildcard, the compiler rejects the program with invalid pattern for CreateTemp. The wildcard is not optional. It forces the runtime to append random bytes, which prevents two concurrent processes from colliding on the same filename.
The defer os.Remove(f.Name()) line schedules the deletion. The f.Name() method returns the full path that the OS actually assigned. You never guess the path. You ask the file handle for it. The deferred call runs when the function returns, whether it exits normally or returns early due to an error. This matches the Go convention of keeping the unhappy path visible. You check every error, you schedule cleanup immediately after creation, and you let the runtime handle the rest.
The OS temp directory resolution happens automatically. Go checks the TMPDIR environment variable first. If that is unset, it falls back to /tmp on Unix systems or %TEMP% on Windows. You do not need to import os just to read environment variables. The standard library handles the platform differences so your code stays portable.
Real world staging pattern
Real code rarely just writes a string and exits. You usually stage data, hand the path to another process, or read it back. Here is a function that writes binary data, closes the file, and returns the path for a downstream consumer.
package main
import (
"fmt"
"os"
)
// StageData writes binary content to a temporary file and returns the path.
// The caller must delete the file when finished.
func StageData(content []byte) (string, error) {
// Create a secure temp file with a .dat extension for clarity.
f, err := os.CreateTemp("", "stage-*.dat")
if err != nil {
return "", fmt.Errorf("create temp: %w", err)
}
// Capture the path immediately so we can return it later.
path := f.Name()
// Write the payload in a single operation.
_, err = f.Write(content)
if err != nil {
// Clean up on write failure before returning the error.
f.Close()
os.Remove(path)
return "", fmt.Errorf("write temp: %w", err)
}
// Close the file to release the OS file descriptor.
if err := f.Close(); err != nil {
os.Remove(path)
return "", fmt.Errorf("close temp: %w", err)
}
return path, nil
}
Notice the error handling pattern. If Write or Close fails, we manually call os.Remove before returning. The defer is omitted here because the function returns a path that belongs to the caller. The caller now owns the cleanup responsibility. This follows the Go style mantra of accepting interfaces and returning concrete values. You return a plain string path, not a file handle, so the caller decides when to read or delete it.
Pitfalls and runtime behavior
Temporary files look simple until they fail. The most common mistake is forgetting to close the file before removing it. On Linux, os.Remove deletes the directory entry but keeps the underlying data alive until the last file descriptor closes. On Windows, the operating system locks open files. If you try to remove a file that is still open, the runtime returns The process cannot access the file because it is being used by another process. Always close before you delete.
Another trap is manual naming. Developers sometimes reach for fmt.Sprintf("temp_%d.txt", time.Now().UnixNano()) to avoid importing os. That approach creates a time of check to time of use race condition. Two goroutines can generate the same timestamp, and the second call to os.Create will overwrite the first file. os.CreateTemp uses cryptographically secure random bytes for the suffix. It blocks until it finds a name that does not exist. You do not need to implement your own uniqueness logic.
Permissions are another quiet gotcha. os.CreateTemp creates files with mode 0600. Only the current user can read or write them. If your downstream process runs as a different user, it will hit a permission denied error. You can change the mode after creation with os.Chmod, but you should only do it when the architecture requires cross user access. The default restrictive mode is intentional. Temporary files often contain sensitive data like tokens or partial uploads.
The compiler will catch obvious mistakes. If you try to pass a non string pattern, you get cannot use x (type int) as string value in argument. If you forget to import os, you get undefined: os. These are straightforward. The runtime errors are where the real work happens. Always check the error from CreateTemp. If the temp directory is full or your process lacks write permissions, the function returns an error instead of panicking.
Testing introduces its own cleanup rules. The testing package provides t.TempDir() to create a temporary directory that the test runner deletes automatically. You can combine t.TempDir() with os.CreateTemp to keep test artifacts isolated. The test framework handles the teardown, so you do not need to write manual cleanup logic in your test functions. Trust the test runner to clean up after your assertions.
When to reach for what
Use os.CreateTemp when you need a secure, auto named scratch file for a single process. Use os.MkdirTemp when you need a temporary directory to hold multiple related files. Use an in memory buffer like bytes.Buffer when the data fits in RAM and you only need a io.Reader or io.Writer interface. Use a fixed path with os.Create only when you control the exact location and name, and you do not need isolation from other processes.