The problem with println
You are building a command-line tool that fetches data from an API. During development, you sprinkle println! everywhere to track variable states and function entry points. The tool works. You ship it to a user. They run it and get a wall of debug text mixed with the actual output. Worse, you realize you cannot turn off the debug noise without recompiling the binary. You need a way to control verbosity, separate concerns, and keep the output clean. You need logging.
Rust handles logging differently than many languages. There is no built-in print equivalent for structured logs. Instead, the ecosystem relies on a facade pattern. The log crate defines the interface, but it does not do the work. You must pair it with a backend implementation. This separation gives you flexibility but introduces a common trap for newcomers: if you add log and forget the backend, your logs vanish into the void with no compiler error.
The facade pattern
The log crate is a facade. A facade is a unified interface that hides the complexity of the subsystems behind it. In Rust, the log crate provides macros like info!, error!, and debug!. These macros check if a log level is enabled and, if so, format the message and pass it to a registered backend. The log crate itself never writes to stdout, stderr, or a file. It only dispatches.
Think of log as a microphone and the backend as the amplifier. The microphone captures the signal, but without the amplifier, nobody hears anything. You can swap the amplifier for a different one without changing the microphone. In Rust terms, you can switch from env_logger to tracing to slog without touching the code that calls info!.
This design solves a dependency problem. If every crate forced you to use its preferred logger, your dependency tree would become a nightmare. By depending on log, crates agree on a common interface. Your application chooses the backend once, and every crate using log automatically routes its messages there.
The log crate is a contract, not a printer. Fulfill the contract or get silence.
Minimal setup
You need two crates: log for the macros and a backend like env_logger to handle the output. env_logger is the standard choice for simple applications. It reads configuration from environment variables and writes to stderr.
Add both to your Cargo.toml.
[dependencies]
log = "0.4"
env_logger = "0.11"
Initialize the backend in main before any logging calls. The env_logger version 0.11 API requires a builder pattern.
use log::info;
fn main() {
// Initialize the backend. Without this, all log messages are discarded.
// The builder reads RUST_LOG from the environment automatically.
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
.init();
info!("Application started successfully");
}
Run the binary with RUST_LOG=info cargo run. You will see the message. Run it without the variable, and the default filter applies. If you set RUST_LOG=debug, you get more detail. The builder pattern lets you set a default level while still allowing environment overrides.
Convention aside: env_logger::Builder::from_env() is the community standard. Hardcoding the filter level in code defeats the purpose of logging. Always allow environment configuration so operators can adjust verbosity without recompiling.
How the macros work
When you write info!("Server listening on {}", port), the macro expands to a runtime check. The compiler generates code that queries the backend to see if the Info level is enabled. If the check fails, the formatting and function call are skipped entirely. This means logging has zero cost when the level is disabled.
If the level is enabled, the macro formats the arguments into a string and calls the backend's log method. The backend receives a Record containing the level, target module, file, line number, and the formatted message. The backend decides what to do. env_logger formats the record and writes it to stderr. Other backends might write to a file, send to a remote service, or buffer the output.
The check happens at runtime by default. However, if you compile with a specific log level disabled via RUST_LOG or build scripts, the compiler can strip the check entirely. This optimization is controlled by the log crate's feature flags. Most users rely on the runtime check, which is fast enough for production workloads.
Treat log levels as a signal-to-noise filter. debug is for you; error is for the operator.
Log levels and targets
The log crate defines five levels, ordered from most verbose to least verbose:
trace: Extremely detailed information, usually only useful for debugging specific issues.debug: Information useful for debugging, such as variable states or function entry.info: Confirmation that things are working as expected. Operational milestones.warn: Something unexpected happened, but the application can continue.error: A serious problem that prevents a specific operation from completing.
Use the levels to structure your output. trace is for inner loops and high-frequency events. debug is for development flow. info is for events that matter in production, like "Server started" or "User logged in". warn indicates recoverable issues, like a missing optional config file. error is for failures that require attention.
You can also set a target for each log message. The target is usually the module path. This lets you filter logs by component. For example, RUST_LOG=my_app::network=debug enables debug logs only for the network module. This is essential for large applications where global debug logging is too noisy.
use log::{debug, error, info, warn};
fn process_request(request: &str) -> Result<String, Box<dyn std::error::Error>> {
// Debug: Development detail. Hidden in production by default.
debug!("Processing request: {}", request);
if request.is_empty() {
// Warn: Unexpected but recoverable.
warn!("Received empty request, using default payload");
return Ok("default".to_string());
}
// Info: Operational milestone.
info!("Request processed successfully");
Ok(request.to_string())
}
fn handle_fatal() {
// Error: Something broke.
error!("Database connection lost");
}
Convention aside: Use the module path as the target by default. The log macros infer the target from the current module. You can override it with the target parameter, but explicit targets are rarely needed unless you are building a library that wants to group logs under a specific name.
Realistic example: A multi-module application
In a real application, you have multiple modules. Each module logs independently. The backend aggregates everything. Here is a structure that demonstrates module-level filtering and proper initialization.
// Cargo.toml dependencies: log = "0.4", env_logger = "0.11"
mod network;
mod storage;
use log::info;
fn main() {
// Initialize env_logger with a default level of info.
// Operators can override with RUST_LOG=debug or RUST_LOG=network=trace.
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
.init();
info!("Starting application");
// Call into modules. Their logs flow through the same backend.
network::fetch_data();
storage::save_data();
info!("Application finished");
}
// src/network.rs
use log::{debug, error, info};
pub fn fetch_data() {
// This log is at debug level. It appears only if RUST_LOG includes debug.
debug!("Connecting to API endpoint");
// Simulate success.
info!("Data fetched from API");
}
// src/storage.rs
use log::{error, warn};
pub fn save_data() {
// Simulate a warning condition.
warn!("Disk usage above 80%, proceeding with caution");
// Simulate a failure.
error!("Failed to write to database: connection timeout");
}
Run this with RUST_LOG=info cargo run. You see the info and warn messages. Run with RUST_LOG=debug cargo run. You see the debug message from network. Run with RUST_LOG=network=trace cargo run. You get trace-level detail only for the network module. This granularity is what makes logging useful in production.
The backend is the single point of control. Configure it once, and every module respects the rules.
Pitfalls and compiler errors
The most common mistake is forgetting to initialize the backend. The log crate compiles fine without a backend. The macros expand to checks that always return false. Your logs disappear. There is no compiler error. You must call init() or Builder::init() in main before any logging happens.
Another trap is the env_logger version mismatch. Version 0.10 used a simple env_logger::init() function. Version 0.11 removed it and requires the builder pattern. If you copy old code and upgrade the dependency, the compiler rejects you with "function or associated item init not found in env_logger". The fix is to switch to Builder::from_env().init().
// This compiles in env_logger 0.10 but fails in 0.11.
// env_logger::init();
// This works in 0.11 and is the recommended pattern.
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
.init();
Be careful with thread safety. The log crate is thread-safe, but the backend must be too. env_logger is thread-safe. If you write a custom backend, you must handle concurrent access. The log crate calls the backend from any thread. Use Mutex or atomic operations in your backend implementation.
Convention aside: Keep unsafe blocks out of logging code unless you are writing a high-performance backend. The standard backends handle synchronization correctly. Reaching for unsafe to speed up logging is rarely worth the risk. Profile first.
When to use log versus alternatives
The Rust logging ecosystem has evolved. log is the established facade, but tracing is the modern alternative. Choosing between them depends on your needs.
Use log when you are writing a library and want maximum compatibility. Most crates depend on log. Using log ensures your library works with any backend the user chooses. Use log when you need simple, unstructured text logging and want minimal dependencies. Use log when your application is small and you do not need structured data or spans.
Use tracing when you are building a new application and need structured logging. tracing supports key-value pairs, which makes parsing logs in tools like Elasticsearch or Datadog much easier. Use tracing when you need async support. tracing integrates with tokio and other async runtimes to provide accurate context across async boundaries. Use tracing when you want spans to track the lifecycle of requests. Spans let you group logs by operation, which is essential for debugging complex flows.
Use env_logger when you need a simple backend controlled by environment variables. It is lightweight and works well with log. Use tracing-subscriber when you are using tracing and need flexible filtering, formatting, and output destinations. Use slog when you need a highly modular, functional-style logging system with explicit context passing.
Pick the facade your ecosystem uses. Switching facades later is a refactoring nightmare.