The wall of text problem
You built a CLI tool that works perfectly on your machine. You added println! statements everywhere to track state while you were coding. Now you ship the binary to a friend. They run it and get a wall of debug text that buries the actual error message. They can't tell what went wrong.
You comment out the debug lines, rebuild, and ship again. A week later, a new bug appears. You have no visibility into what the program is doing. You uncomment the lines, rebuild, and repeat. Printing to stdout is a sledgehammer. It's either on or off, and changing it requires recompiling. You need a way to control verbosity without touching the binary, and you need it to work across different crates without passing a logger object around.
That's what env_logger solves. It gives you a remote control for your logs. You configure the behavior using the RUST_LOG environment variable, and the logger respects those settings at runtime. You can turn up the volume on a specific module, mute background noise, or silence everything with a single flag. No rebuilds. No refactoring.
The soundboard analogy
Rust separates the act of logging from the destination. The log crate provides the macros like info!, warn!, and error!. It is a facade. It defines the interface but does not print anything by itself. env_logger is the implementation that listens to the environment and decides what gets printed.
Think of log as a network of microphones scattered through your code. Every crate can plug into the network. env_logger is the soundboard. You connect the microphones to the board, and then you use the RUST_LOG variable to adjust the faders. You can crank up the gain for the "network" channel while muting the "database" channel. The microphones stay in place; only the output changes.
This separation is the key to Rust's logging ecosystem. Libraries can use the log macros without forcing a specific logger on your application. You choose the backend that fits your needs, and all the libraries just work.
Setting up the logger
You need two crates: log for the macros and env_logger for the backend. Add both to your Cargo.toml.
[dependencies]
env_logger = "0.11"
log = "0.4"
The log crate provides the trait definitions and macros. env_logger depends on log, but it does not re-export the macros for you. You must list log explicitly to use info! and friends. If you forget this, the compiler rejects the import with E0432 (use of undeclared crate or module).
Initialize the logger at the top of main. This reads the RUST_LOG variable and sets up the filter.
use log::{info, error, warn};
fn main() {
// Initialize the logger once.
// This reads RUST_LOG and configures the backend.
env_logger::init();
info!("Server listening on port 8080");
warn!("Config file missing, using defaults");
error!("Failed to connect to database");
}
Run the program with RUST_LOG=info to see output. Without the variable, the logger stays silent. The init call is cheap, but it should happen exactly once. Calling it multiple times is a code smell. In newer versions of env_logger, repeated calls are no-ops, but the convention is to initialize at the entry point and leave it alone.
Convention aside: The community prefers env_logger::try_init() over init(). The try_init function returns a Result, allowing you to handle initialization failures gracefully. If the RUST_LOG syntax is malformed, try_init lets you log the error or exit cleanly. Use try_init in production code; init is acceptable for quick scripts.
How RUST_LOG controls the flow
The RUST_LOG variable uses a simple syntax to control which messages appear. The logger checks this variable once during initialization and builds a filter map. Every log call checks the map at runtime. If the message passes the filter, it gets formatted and sent to stderr. If not, the call is a no-op. The overhead is tiny because the check happens before any string formatting.
The levels form a hierarchy. Setting a level enables that level and everything above it.
error: Only errors.warn: Warnings and errors.info: Info, warnings, and errors.debug: Debug, info, warnings, and errors.trace: All messages.
Run with RUST_LOG=debug to see everything. Run with RUST_LOG=error to see only critical failures. The default is off. If you want a sensible default for development, set RUST_LOG=info in your shell profile or use the Builder pattern to parse a default value.
You can target specific modules or crates. The syntax uses the module path.
# Enable debug for the entire app
RUST_LOG=debug ./my_app
# Enable debug only for the network module
RUST_LOG=my_app::network=debug ./my_app
# Enable debug for a third-party crate
RUST_LOG=tokio=debug ./my_app
# Combine multiple filters with commas
RUST_LOG=info,my_app::db=debug ./my_app
The filter applies to the module path where the macro is called. If you call debug! inside my_app::network::connect, the path is my_app::network::connect. Setting RUST_LOG=my_app::network=debug matches this path and enables the log. Setting RUST_LOG=my_app=debug also matches, because the filter applies to the prefix.
Convention aside: env_logger writes to stderr by default. This is intentional. Stdout is reserved for program output that other tools might parse. Stderr is for diagnostics. This separation lets you pipe the data stream to a file while keeping logs on the terminal. ./my_app > output.txt captures the data; the logs still appear on screen.
The log facade architecture
The split between log and env_logger is not an accident. It is a deliberate design pattern called a facade. The log crate defines a trait-based interface. Crates implement this interface to provide logging backends. Applications select a backend at runtime.
This architecture solves a dependency problem. Imagine a library that wants to log debug information. If the library depends on env_logger, every application using that library must also depend on env_logger. If another library uses tracing, you end up with conflicting loggers. The application has no control.
With the facade, the library depends only on log. It calls debug! and moves on. The application depends on log and env_logger. The application initializes env_logger, which registers itself as the global logger. When the library calls debug!, the call routes through the facade to env_logger. The library knows nothing about the backend. The application controls the configuration.
This pattern appears elsewhere in Rust. The serde crate uses a similar approach for serialization. The log facade is the standard for logging in the ecosystem. Most crates use it. If you see a crate using println! for logging, it is likely a sign of immaturity.
Realistic usage with targets
In larger applications, you often need to distinguish between different subsystems. The log macros support a target parameter. This lets you assign a custom tag to a log message, independent of the module path.
mod database {
use log::{debug, error};
pub fn query(sql: &str) {
debug!(target: "db", "Executing query: {}", sql);
// Simulate work
if sql.contains("DROP") {
error!(target: "db", "Blocked dangerous query");
}
}
}
mod auth {
use log::warn;
pub fn check_token(token: &str) {
if token.is_empty() {
warn!(target: "auth", "Empty token received");
}
}
}
fn main() {
env_logger::init();
log::info!("Application starting");
database::query("SELECT * FROM users");
auth::check_token("");
}
The target parameter overrides the default module path. You can filter by target using the same RUST_LOG syntax.
# Enable debug for the database target
RUST_LOG=db=debug ./my_app
# Enable debug for auth and database
RUST_LOG=auth=debug,db=debug ./my_app
This is useful when you want to group logs by logical subsystem rather than file structure. You can move code between modules without breaking your log filters. The target stays consistent.
Convention aside: Use lowercase targets with underscores or hyphens. db, auth, network_io. Avoid module paths in targets unless you are mirroring the module structure. Targets should be stable identifiers that make sense in a filter string.
Pitfalls and gotchas
Forgetting to initialize the logger is the most common mistake. If you call info! without calling env_logger::init(), nothing happens. The logs are silently dropped. The compiler does not warn you. The facade requires a backend to be registered. If no backend is registered, the macros compile to no-ops. Always initialize the logger before any logging calls.
Another trap is assuming RUST_LOG is case-insensitive. The level names are case-insensitive (DEBUG, debug, Debug all work). Module paths and targets are case-sensitive. RUST_LOG=MyApp=debug does not match my_app. Use lowercase for consistency.
The init function parses the environment variable immediately. If the syntax is invalid, init panics in some versions or ignores the variable in others. Use try_init to handle errors. If you need to validate the configuration, check the result.
if let Err(e) = env_logger::try_init() {
eprintln!("Failed to initialize logger: {}", e);
}
This pattern prevents panics from malformed environment variables. It is especially important in libraries that might initialize logging on behalf of the application.
Convention aside: Keep env_logger blocks small and isolated. Do not wrap large sections of logic in logger configuration. Initialize once, log everywhere. If you need dynamic configuration, use env_logger::Builder to construct the logger with custom rules, then initialize. Avoid calling init inside loops or request handlers.
Decision: when to use this vs alternatives
Use env_logger when you need a simple, zero-config logger for a CLI tool or a small service. It works out of the box with environment variables and has almost no setup cost.
Use tracing when you are building a complex async application or need structured data with spans and fields. tracing gives you hierarchical context that env_logger cannot provide. It tracks the flow of execution across async boundaries and lets you attach metadata to specific operations.
Use fern when you need fine-grained control over destinations, like writing errors to a file and info to stdout, or custom formatting rules. fern is a configuration engine, not just an environment parser. It excels at routing logs to multiple outputs with different formats.
Reach for println! only during rapid prototyping. Switch to log macros before the code leaves your local machine. println! cannot be filtered, cannot be redirected, and clutters the output stream. Treat logging as a first-class concern from day one.
Trust the facade. It keeps your dependencies clean and your options open.