When the server goes silent
You deploy a Go service. It handles traffic for three hours. Then the response times spike and HTTP 500s start rolling in. You SSH into the host and run tail -f. The screen is empty. Without logs, you are guessing. The log package exists to stop that guessing. It writes plain text to a stream, safely, with a timestamp. That is its entire job.
Boring is good. Logging should not be a source of bugs. The log package is simple, thread-safe, and part of the standard library. It works everywhere Go runs. You do not need to install dependencies to see what your program is doing.
Keep it boring. Simple logging breaks less than clever logging.
Logging is just controlled output
Logging is input and output. Input and output is slow. Go's log package wraps fmt and an io.Writer. It adds a mutex so goroutines do not interleave their messages. It adds a prefix and flags. There is no magic.
Think of it like a security guard at a building entrance. The guard checks the time, writes it on a clipboard, and passes your note to the archive room. Multiple people can hand notes to the guard at once. The guard processes them one by one so the archive stays in order. The log package is the guard. The io.Writer is the archive room.
The package is thread-safe by design. You can call log.Println from ten goroutines and the output stays coherent. The lock ensures one message finishes before the next starts. This makes the logger safe to share across goroutines. The default logger writes to os.Stderr. Standard error is the right place for logs. Standard output is for data. If you pipe standard output to a file, you do not want logs mixed in.
Trust the guard. The mutex handles the concurrency. You handle the message.
The simplest call
Here is the simplest way to log. You import the package and call a print function.
package main
import "log"
func main() {
// Default logger writes to os.Stderr with a timestamp
// Println adds a newline automatically
log.Println("Server starting up")
// Printf formats the message like fmt.Printf
// It is useful for including variables in the log line
log.Printf("Listening on port %d", 8080)
}
The default logger includes a timestamp. It uses the LstdFlags constant, which combines Ldate and Ltime. The output looks like 2023/10/25 10:00:00 Server starting up. The timestamp helps you correlate events. Without it, you just have a list of strings with no context.
If you pass the wrong number of arguments to Printf, the compiler rejects the program with too many arguments to fmt.Printf or not enough arguments. The formatting rules match fmt exactly. Learn the verbs once and they work everywhere.
How the mutex keeps output coherent
When you call Println, the logger acquires a mutex. It checks the flags. If Ldate is set, it appends the date. If Ltime, the time. It writes to the underlying writer. It releases the mutex. The lock is the key feature.
Without the lock, concurrent writes would produce garbled output. Two goroutines could write to the file at the same time. Their bytes would mix. You would see half of one message followed by half of another. The mutex protects the writer from concurrent access. This makes the logger safe to share across goroutines.
The writer is an io.Writer. Anything that implements the Write method works. The log package does not care where the bytes go. It just calls Write. This decouples formatting from destination. You can log to a file, a network connection, or a buffer. The logger handles the locking and formatting. The writer handles the I/O.
The io.Writer contract is just one method: Write(p []byte) (n int, err error). The logger ignores the error return value by design. It assumes the writer handles its own failures. This keeps the logging API fast and predictable. If your writer panics on error, that is your writer's problem, not the logger's.
Write to io.Writer. Let the interface do the heavy lifting.
Custom loggers and the io.Writer contract
The default logger is global. Global state is hard to test. It is also hard to configure per-component. The New function creates a custom logger. It takes three arguments. The first is the io.Writer. The second is the prefix. The third is the flags.
The prefix appears before the flags in the output. The flags control what metadata gets appended. You combine flags using the bitwise OR operator. This lets you pick exactly what you need.
Here is a logger that writes to a file and includes source location.
package main
import (
"log"
"os"
)
func main() {
// Open a file for appending logs
// O_CREATE creates the file if it does not exist
// O_APPEND ensures new logs go to the end
f, err := os.OpenFile("app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
// Fatal exits immediately if the file cannot be opened
log.Fatal(err)
}
// Close the file when main exits to flush buffers
defer f.Close()
// Create a logger with date, time, and file/line info
// Lshortfile adds "filename.go:123" to each message
logger := log.New(f, "APP: ", log.Ldate|log.Ltime|log.Lshortfile)
logger.Println("Database connection established")
logger.Printf("Processing batch %d", 42)
}
The Lshortfile flag adds the file name and line number. This is useful for debugging. You can see exactly where the log call happened. The Llongfile flag adds the full path. Use Lshortfile for most cases. The full path is rarely helpful and makes logs noisy.
The Lmicroseconds flag adds microseconds to the timestamp. This is useful for debugging concurrency. If two goroutines log at the same time, milliseconds might not be enough to distinguish them. Microseconds give you more precision.
The Lmsgprefix flag moves the prefix to after the flags. This is useful if you want the timestamp first. The default order is prefix then flags. Lmsgprefix swaps them.
Testing code that logs is hard if the logger writes to a file or standard error. You want to capture the output and assert on it. The io.Writer interface makes this easy. You can pass a bytes.Buffer to the logger. The buffer captures the bytes in memory. You can read them later.
Here is how to capture logs in a test.
package main
import (
"bytes"
"log"
"testing"
)
func TestLogCapture(t *testing.T) {
// Create a buffer to capture log output
var buf bytes.Buffer
// Create a logger that writes to the buffer
// No flags for simplicity in this test
logger := log.New(&buf, "", 0)
// Log a message
logger.Println("Test message")
// Check the captured output
// The buffer contains the message plus a newline
if buf.Len() == 0 {
t.Error("Expected log output, got nothing")
}
}
The buffer implements io.Writer. The logger treats it like any other writer. It writes the bytes to the buffer. You can check the buffer length or content. This pattern works for any function that accepts a writer. It is a standard Go idiom for testing I/O.
Capture to a buffer. Assert on the bytes. Skip the disk.
The Fatal trap and runtime behavior
The Fatal functions are dangerous. log.Fatal calls os.Exit(1). os.Exit terminates the program immediately. Defers do not run. If you have cleanup code, Fatal kills it.
This is the most common logging bug. You write a defer to close a database connection. You call log.Fatal on an error. The program exits. The connection stays open. The database runs out of connections.
Here is how the trap looks in code.
package main
import (
"log"
"os"
)
func main() {
// Open a file
f, err := os.OpenFile("data.txt", os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Fatal(err)
}
// This defer never runs if Fatal is called above
defer f.Close()
// If you reach here, the file is open
log.Println("File is open")
}
The compiler does not warn you about this. It is a runtime behavior. The log package calls os.Exit. os.Exit stops everything. The defer is scheduled but never executed. This is by design. Fatal means "stop now". If you need cleanup, do not use Fatal.
Use log.Panic if you want the stack to unwind and defers to run. Panic is also heavy. It unwinds the stack. Use it sparingly. For everything else, return the error or use log.Printf. If you need to exit, call os.Exit manually after running your defers.
Never let Fatal skip your cleanup. Return the error instead.
Conventions that pay off
The Go community has strong conventions around logging. Follow them to keep your code readable.
Use log.Printf instead of fmt.Printf for logging. fmt does not add timestamps or prefixes. log does. If you use fmt, you lose the metadata. The extra import is worth it.
Do not use the default logger in a library. Libraries should not have side effects. If a library uses the default logger, it writes to standard error for every user. The user cannot control the output. Create a custom logger or accept a logger interface. Let the application decide where logs go.
The receiver name is usually one or two letters matching the type. If you create a logger type, use (l *Logger). Not (this *Logger). Go convention favors short names.
Public names start with a capital letter. Private names start lowercase. If you export a logger, name it Logger. If it is internal, name it logger. This controls visibility.
Error handling is verbose by design. if err != nil { return err } is the standard pattern. Logging an error and continuing is sometimes necessary. Logging an error and exiting is rare. Return the error. Let the caller decide.
gofmt is mandatory. Do not argue about indentation. Let the tool decide. Most editors run it on save. Your logs will be consistent. Your code will be consistent.
Accept interfaces, return structs. Pass an io.Writer to your logging function. Return a concrete logger instance. The pattern scales.
Follow the conventions. The community expects them.
Decision matrix
Use the log package when you need simple text logs for a script or small service. It is fast to set up and requires no dependencies.
Use a custom logger with log.New when you need to control the output destination or format. This is essential for libraries and multi-component applications.
Use log/slog when you need structured logging with key-value pairs. slog is the modern successor to log in the standard library. It supports context and structured data.
Use a third-party library like zap or logrus when you need high-performance structured logging with sampling and custom encoders. These libraries are faster and more feature-rich. They add complexity. Only use them if you need the features.
The log package is simple. Simple things break less. Start with log. Move to slog or zap only when you hit a limit.
Trust the mutex. The logger is safe. Write to a buffer in tests. Avoid Fatal unless you mean it.