How to use log crate in Rust logging facade

Add log and env_logger to Cargo.toml, initialize the backend in main, and use log macros to output messages.

The problem with println!

You are debugging a command-line tool that suddenly stops processing files after the third batch. You scattered println! statements everywhere, but now the terminal output is a wall of text mixed with actual program results. You need timestamps. You need severity levels. You need to turn off the noise without deleting code or leaving dead println! calls in your repository. That is exactly where the log crate steps in.

The facade pattern explained

The log crate does not actually print anything to your terminal. It is a facade. Think of it like a standard electrical outlet in your house. The outlet defines the shape, the voltage rules, and how you plug things in. It does not generate the electricity. The power grid does. In Rust, log defines the interface for recording messages. A separate backend crate, like env_logger or tracing, generates the actual output and handles the routing. This separation lets you swap logging systems without touching your application code.

The facade pattern exists because Rust applications often depend on third-party libraries. If every library forced you to use its own logging system, your Cargo.toml would become a tangled mess of incompatible backends. Instead, libraries depend on log. You pick one backend for your entire project. The backend registers itself with log. Every library in your dependency tree suddenly works.

Minimal setup

You need two crates. The log crate provides the macros and traits. The env_logger crate provides a simple, environment-driven backend. Add them to your Cargo.toml.

[dependencies]
log = "0.4"
env_logger = "0.11"

Initialize the backend before any logging calls happen. The env_logger builder pattern lets you set defaults without hardcoding them into your binary.

// main.rs
use log::{info, LevelFilter};

/// Entry point for the application.
/// Initializes the logging backend before any other work begins.
fn main() {
    // The builder pattern parses environment variables automatically.
    // We set a default filter so the app works out of the box.
    env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
        .format_timestamp(None)
        .init();

    // Macros expand to trait calls. The backend receives the record.
    info!("Application started");
}

Run the binary. You will see the message printed to standard error. Standard error is the convention for logs because it keeps diagnostic output separate from program output. Pipe stdout to a file and your logs still appear in the terminal.

What happens under the hood

When you call info!, the macro does not invoke a function. It expands to a trait method call on a global logger. The macro captures the file path, line number, module path, and message string. It packages them into a Record struct. The log crate checks a thread-local filter. If the filter allows the level, it forwards the Record to the registered backend.

The backend receives the Record and formats it. env_logger applies its own filters, formats the timestamp, prefixes the level, and writes to stderr. If you forget to call .init(), the program panics at runtime with a "logger not initialized" message. The compiler cannot catch this because logger registration happens at runtime. You must guarantee initialization happens before the first log call.

Log levels are not just runtime checks. The log crate uses compile-time feature flags to strip disabled levels entirely. When you compile with --release, debug! and trace! macros expand to empty statements by default. The formatting code never runs. The string literals never enter the binary. This keeps release builds fast and small.

Realistic multi-module example

Real applications span multiple modules. Logging should flow across module boundaries without extra configuration. Create a utils module that performs heavy work. Use different log levels to separate routine operations from warnings and errors.

// src/utils.rs
use log::{debug, warn};

/// Processes a batch of items and logs progress.
/// Returns the count of successfully processed items.
pub fn process_batch(items: &[&str]) -> usize {
    let mut success_count = 0;

    for item in items {
        // Debug logs track routine progress. They disappear in release builds.
        debug!("Processing item: {}", item);

        if item.is_empty() {
            // Warnings indicate recoverable issues. They stay visible by default.
            warn!("Skipping empty item");
            continue;
        }

        // Simulate work. In a real app, this would be I/O or computation.
        success_count += 1;
    }

    success_count
}

Call it from main. The backend automatically routes messages from utils to the same output stream.

// src/main.rs
mod utils;

use log::{info, error};

/// Entry point for the application.
/// Initializes logging and runs the processing pipeline.
fn main() {
    env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
        .init();

    let items = vec!["alpha", "", "gamma", "delta"];
    
    info!("Starting batch processing");
    
    let count = utils::process_batch(&items);
    
    if count < items.len() {
        // Errors indicate failures that require attention.
        error!("Batch completed with missing items");
    }
    
    info!("Finished processing {} items", count);
}

Run the binary with RUST_LOG=debug cargo run. The env_logger backend parses the environment variable and overrides the default filter. You will see every debug! call from both modules. The RUST_LOG environment variable is a community standard. Almost every Rust logging backend respects it automatically. Set it once and your entire dependency tree follows.

Pitfalls and performance traps

Logging introduces runtime overhead if you ignore how the macros expand. The biggest trap is eager string formatting. If you write debug!("{}", format!("Value: {}", expensive_function())), the format! call runs before the log level check. The expensive function executes even when logging is disabled. Always pass arguments directly to the macro. The macro handles lazy evaluation. Write debug!("Value: {}", expensive_function()) instead. The formatting only happens if the level passes the filter.

Another trap is assuming logs are thread-safe by default. The log facade is thread-safe, but some backends have limits. env_logger uses a global mutex for formatting. It works fine for simple applications. High-throughput servers may experience contention. Switch to a backend designed for concurrency if you log thousands of messages per second.

Trait bounds also catch developers off guard. If you try to log a custom struct without implementing Display or Debug, the compiler rejects you with E0277 (trait bound not satisfied). The macro requires {:?} or {} formatting. Implement the trait or use log::debug!("{:?}", my_struct) explicitly.

Do not log sensitive data. Environment variables, API keys, and user credentials should never pass through logging macros. Logs often ship to external services. Once a secret is in a log stream, it is considered compromised.

Choosing your logging stack

Use log with env_logger when you need a lightweight, zero-configuration setup for CLI tools or simple servers. Use tracing when you are building a concurrent application that needs structured data, spans, and async compatibility. Use slog when you want a highly modular, functional-style logging pipeline with custom sinks. Use defmt when you are targeting microcontrollers where every byte of flash and every CPU cycle matters.

Pick the backend that matches your runtime requirements. The log facade will route everything correctly regardless of your choice.

Where to go next