How to Log to a File in Go

Log to a file in Go by opening a file with os.OpenFile and creating a new logger instance pointing to it.

The problem with vanishing output

You wrote a Go script that processes data. It works perfectly in the terminal. You run it as a cron job or a systemd service, and suddenly the output disappears into the void. You need to know what happened, but the logs are gone. Writing to a file captures the output so you can inspect it later, even when no one is watching the terminal.

Go does not have a special "log to file" function. The standard library treats logging as writing to a destination. By default, that destination is standard error. You can swap the destination for a file by opening the file and handing it to the logger. The logger writes bytes to whatever you give it.

Swapping the destination

The log package writes to an io.Writer. Standard error implements io.Writer. A file implements io.Writer. The logger doesn't care which one you use. You open a file with os.OpenFile, get a *os.File, and pass that file to log.New. The new logger writes to the file instead of the terminal.

This pattern follows the Go mantra: accept interfaces, return structs. log.New accepts an io.Writer interface and returns a concrete *log.Logger. You can swap the writer later without changing the logger code.

Minimal example

Here's the simplest way to write a message to a file. Open the file with append mode, create a logger, write, and close.

package main

import (
	"log"
	"os"
)

func main() {
	// O_CREATE makes the file if missing. O_WRONLY opens for writing only.
	// O_APPEND ensures new logs go to the end, not overwriting old data.
	f, err := os.OpenFile("app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
	if err != nil {
		// Fatal prints the error to stderr and calls os.Exit(1).
		log.Fatal(err)
	}
	// Close the file when main returns to avoid resource leaks.
	defer f.Close()

	// New creates a logger that writes to f. Empty prefix and flags mean no timestamp.
	logger := log.New(f, "", 0)
	logger.Println("Hello, log file!")
}

File flags control behavior

os.OpenFile takes four arguments: filename, flags, permissions, and an optional third argument for some systems. The flags determine how the file opens. They are bitmasks, so you combine them with the bitwise OR operator |.

os.O_CREATE creates the file if it does not exist. os.O_WRONLY opens the file for writing only. os.O_APPEND is mandatory for logging. Without it, every write starts at the beginning of the file. You overwrite previous logs on every call. The compiler allows you to omit O_APPEND. The runtime obeys your choice. You lose data.

os.O_TRUNC truncates the file to zero length on open. This is the enemy of logging. If you add O_TRUNC, every run wipes the file. Use O_TRUNC only when you want a fresh file every time.

Permissions are octal integers. 0666 means read and write for owner, group, and others. The system's umask reduces these permissions. A umask of 022 turns 0666 into 0644. This is standard Unix behavior. Use 0644 for logs that should be readable by others but writable only by the owner.

If you pass a decimal integer for permissions, the compiler rejects it with cannot use 666 (untyped int constant) as os.FileMode value in argument. Permissions must be os.FileMode. Write 0666 or os.FileMode(0666). The leading zero indicates octal notation.

Logger flags add metadata

log.New takes three arguments: the writer, a prefix string, and flags. The prefix appears before every log line. Flags control what metadata gets added.

0 means no metadata. log.LstdFlags adds the date and time. It is equivalent to log.Ldate | log.Ltime. log.Lmicroseconds adds microsecond precision. log.Lshortfile adds the filename and line number. log.Llongfile adds the full path.

log.Lmsgprefix puts the prefix before the message instead of after. This changes the output from 2023/10/25 10:00:00 APP: message to APP: 2023/10/25 10:00:00 message.

log.Lshortfile is expensive. It calls runtime.Caller to find the source location. Use it only for debug builds. Production logs should skip file info to keep performance high.

The log package functions like Println and Fatal use the default logger. When you create a custom logger with log.New, you must call methods on that instance. Calling log.Println after creating a custom logger still writes to stderr, not your file. Always use the variable you created.

Realistic setup with cleanup

Real programs need timestamps and reliable cleanup. Here's a setup that adds time to logs and returns a cleanup function to close the file.

package main

import (
	"log"
	"os"
)

// setupLogger opens a file and returns a logger plus a cleanup function.
// This pattern ensures the file handle is closed when the logger is done.
func setupLogger(filename string) (*log.Logger, func(), error) {
	// O_CREATE|O_WRONLY|O_APPEND creates or opens for appending.
	// 0644 sets permissions: read/write owner, read group/others.
	f, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
	if err != nil {
		return nil, nil, err
	}

	// LstdFlags adds date and time. Lmsgprefix places the prefix before the message.
	logger := log.New(f, "APP: ", log.LstdFlags|log.Lmsgprefix)

	// Return logger and a closure that closes the file.
	return logger, func() { f.Close() }, nil
}

The cleanup function captures the file handle in a closure. The caller defers the cleanup to ensure the file closes when the program exits.

func main() {
	logger, cleanup, err := setupLogger("server.log")
	if err != nil {
		// Fatal prints to stderr and calls os.Exit(1).
		log.Fatalf("Cannot start logging: %v", err)
	}
	// Ensure the file closes when main exits.
	defer cleanup()

	logger.Println("Service initialized")
}

log.Fatal calls os.Exit(1). Defers run before the process terminates. The cleanup function executes. The file closes. If you have multiple goroutines, os.Exit stops the world. Other goroutines may not finish their work. Use log.Fatal only for unrecoverable setup errors.

Pitfalls and runtime traps

Forgetting O_APPEND destroys logs. The compiler won't stop you. You just lose data. Check your flags carefully.

The logger does not close the file. log.New returns a *log.Logger. It holds an io.Writer. It does not know the writer is a file. You must close the file yourself. If you forget, the file handle leaks. On Linux, you hit a limit of open files. The program crashes with too many open files.

Concurrent writes are safe. log.Logger has an internal mutex. The Print methods lock before writing. You can share one logger across goroutines safely. High contention on the mutex can slow down logging. If you need extreme throughput, consider a third-party logger with async writing.

File permissions can block logging. If the program runs as a different user, it might not have write access. os.OpenFile returns an error. Handle the error. Do not ignore it. If you ignore the error, the logger writes to a nil writer and panics.

The io.Writer abstraction

The io.Writer interface requires one method: Write(p []byte) (n int, err error). Any type with this method works with log.New. This abstraction is powerful. You can log to files, network streams, buffers, or custom sinks using the same logger type.

You can chain writers. Wrap a file with a buffer to reduce system calls. Wrap a writer with a compressor to save disk space. The logger sees only the io.Writer interface.

This design keeps the log package small. It delegates file handling to os. It delegates buffering to bufio. You compose the behavior you need.

Go conventions dictate that context.Context goes as the first parameter. Logging functions often need context for trace IDs. The standard log package does not support context. You must pass context manually or use log/slog. slog adds context and structured attributes. It is part of the standard library since Go 1.21.

Decision matrix

Use the standard log package when you need simple text logs with minimal setup.

Use log/slog when you want structured logging with context and attributes without external dependencies.

Use a third-party logger like Zap or Logrus when you need high-performance logging or advanced features like sampling and dynamic levels.

Use standard error only when the program runs interactively or is managed by a container orchestrator that captures stdout and stderr.

The standard library covers most needs. Reach for external tools only when you measure a bottleneck or need specific features.

Goroutines are cheap. File handles are not. Close your files.

Where to go next