Debugging without recompiling
You're building a CLI tool. It works, but debugging is a nightmare. You have println! scattered everywhere. To find a bug, you comment out half the prints, recompile, run, then uncomment, recompile, run. It's slow. You want a way to turn logging on and off, filter by module, and change verbosity without touching the code. That's where env_logger comes in.
Stop recompiling to debug. Let the environment do the work.
The facade pattern
Rust separates the logging interface from the implementation. The log crate defines the macros like info! and error!. It doesn't do anything on its own. It's a facade. env_logger is a logger implementation that reads configuration from environment variables. You use log to write logs, and env_logger to decide where they go and which ones show up.
This separation lets you swap loggers later without changing your logging calls. If you start with env_logger and later need structured logging, you can switch to tracing or slog by changing one line in main and updating Cargo.toml. Your info! calls stay the same.
Keep the interface separate from the implementation. You'll thank yourself when you swap loggers later.
Getting started
Add both crates to your project. env_logger depends on log, but you need to list log explicitly if you use its macros.
[dependencies]
log = "0.4"
env_logger = "0.10"
Initialize the logger in main before any logging calls. This installs env_logger as the global logger for the process.
use log::{info, warn, error};
fn main() {
// Initialize env_logger. This reads RUST_LOG from the environment.
// It must run before any log macros are called.
env_logger::init();
info!("Application started");
warn!("Configuration file missing, using defaults");
error!("Failed to bind to port 8080");
}
Run the program. By default, env_logger shows only error level logs. You'll see the error message, but not the info or warn messages.
Set the RUST_LOG environment variable to control verbosity. Run RUST_LOG=info cargo run to see info, warn, and error messages. The levels are hierarchical: trace, debug, info, warn, error. Enabling a level includes all higher levels.
How the filter works
When env_logger::init() runs, it parses the RUST_LOG variable and installs a filter. Every time you call a log macro, the logger checks the filter. If the level is disabled, the message is dropped immediately.
Here's the surprising part: the logging macros are lazy. If the level is filtered out, the arguments aren't evaluated. You can write debug!("State: {}", compute_expensive_state()) and compute_expensive_state() won't run when debug logging is off. This is a performance win. You don't need if log_enabled!(Debug) guards. The macro handles it.
This lazy evaluation means you can leave debug logging in your code without worrying about runtime cost in production. The compiler generates the check, and the logger skips the formatting and argument evaluation when the level is disabled.
Trust the lazy evaluation. Write verbose debug logs freely; they cost nothing when turned off.
Real-world filtering
Real applications have many modules. You might want debug logs for your network code but only errors for the rest. env_logger supports module filtering. The filter uses the module path. If your code is in src/network/mod.rs, the module path is my_crate::network.
You can target specific modules in RUST_LOG. Run RUST_LOG=network=debug,info cargo run. This shows debug for the network module and info for everything else. You can chain multiple filters. RUST_LOG=network=debug,auth=debug,db=error enables debug for network and auth, but only errors for the database module.
The filter syntax supports wildcards in some versions, but the safest approach is explicit module paths. If you have a submodule my_crate::network::tcp, setting RUST_LOG=network=debug enables debug for the entire network tree. The filter matches prefixes.
Use module filtering to isolate problems. Enable verbose logging for the subsystem you're debugging without drowning in noise from the rest of the app.
Customizing output
Sometimes you need more control than RUST_LOG provides. env_logger::Builder lets you customize formatting or set defaults before initialization. This is useful when you need JSON output, specific timestamps, or custom colors.
use env_logger::Builder;
fn main() {
// Use Builder to customize the logger before initializing.
// from_env() reads RUST_LOG, but you can override defaults.
Builder::from_env()
// Add a timestamp to every log line.
.format(|buf, record| {
writeln!(buf, "[{}] {}", record.level(), record.args())
})
.init();
log::info!("Custom formatted log");
}
The format method takes a closure that receives a buffer and the log record. You can write anything you want to the buffer. This is how you add timestamps, file names, or line numbers.
Convention aside: The community prefers simple formatting for CLIs and JSON for servers. If you're building a service, consider env_logger with a JSON formatter or switch to tracing with tracing-subscriber.
Pitfalls and gotchas
If you forget to add log to your dependencies, the compiler rejects your code with E0433 (can't find crate log). You need both crates. env_logger brings log as a dependency, but you must list it in Cargo.toml to use the macros.
If you call env_logger::init() twice, the program panics. This happens often in tests or when a dependency also initializes the logger. Use env_logger::try_init() to handle this gracefully. It returns a Result instead of panicking. This is the standard pattern for libraries and test code.
fn main() {
// try_init returns Ok(()) if successful, or Err if already initialized.
// This prevents panics in tests or when dependencies init the logger.
let _ = env_logger::try_init();
log::info!("Safe initialization");
}
If RUST_LOG is missing, env_logger defaults to showing only error level logs. This can be confusing when you expect to see info messages. Always set RUST_LOG during development. A common development workflow is to add RUST_LOG=debug to your shell profile or use a wrapper script.
Call try_init() in libraries. Let the binary decide when the logger starts.
Choosing your logger
Rust has several logging crates. Pick the one that matches your needs.
Use env_logger when you want quick setup with environment variable control and don't need structured data. Use fern when you require multiple output destinations, custom formatters, or complex filter logic that RUST_LOG syntax can't express. Use tracing when you're building async applications or need structured events with spans for distributed tracing.
env_logger is the workhorse for CLIs and simple servers. It's lightweight, well-understood, and integrates with the log facade. If you need more power, fern offers flexible configuration. If you need async support and structured data, tracing is the modern choice.
Pick the tool that matches your complexity. Don't over-engineer logging for a simple script.