A direct comparison of Rust's two main logging approaches, tracing and log, with clear guidance on which to choose for your project in 2026. Covers structured logging, async contexts, performance, and the ecosystem around each.
If you are building a service, API, or any async production backend in 2026, tracing is usually the right default. If you are writing a library, CLI, or simpler application, log is still a perfectly valid choice.
By Max Wells, updated August 2026
TL;DR: Use
tracingfor web services, APIs, and async applications. Uselogfor libraries, CLI tools, and simple applications where structured context is not needed. The community has largely settled ontracingfor production services;logremains valid for simpler use cases and libraries.
- tracing: structured spans, async-aware context, OpenTelemetry integration, production default
- log: simple, minimal, widely compatible, correct for libraries
- Migration: going from
logtotracingis easy (tracing is backward compatible with log)- Performance: both are zero-cost when disabled; tracing has small overhead per span when enabled
- 2026 standard: most new Rust services use
tracing+tracing-subscriber
Who Should Read This?
This comparison is for Rust developers starting a new project or evaluating their current logging setup who want a clear, opinionated answer about which approach to use. The log vs tracing debate comes up regularly in Rust communities, and the right answer depends on your use case. If you are building an API, a microservice, or anything that runs in production and needs debugging context, this article gives you the framework for the decision and enough code examples to evaluate both against your specific requirements.
Bottom line: Best default for services and async apps:
tracing. Best default for libraries and simple tooling:log.
Which One Should You Choose in 2026?
Choose tracing if your application has async workflows, request context, or production observability needs. Choose log if your application is simple, synchronous, or intended to be embedded as a library inside someone else's stack.
Use this quick filter:
- Choose
tracingif you are building an API, worker, background service, or anything where request/task context matters. - Choose
logif you are writing a library, a small CLI, or an application where flat log lines are enough. - Do not over-optimize for future observability if the app is tiny today. Do not under-invest in context if the app already has concurrent or production debugging complexity.
What Are These Crates and What Problem Do They Solve?
log: The Original
log is Rust's original logging facade, part of the core ecosystem since Rust 1.0. It provides a simple, macro-based API:
use log::{info, warn, error, debug};
fn process_request(id: u64) {
info!("Processing request {}", id);
// ... do work ...
warn!("Request {} took longer than expected", id);
}log requires a backend (subscriber) to actually output logs: env_logger, pretty_env_logger, fern, etc.
tracing: Structured and Async-Aware
tracing extends the logging concept to include spans: structured, hierarchical contexts that track what code is executing and what the context is:
use tracing::{info, warn, instrument, span, Level};
#[instrument(fields(user_id = %user_id))]
async fn process_request(user_id: u64) {
info!("Processing request");
// All events inside this function automatically carry user_id context
warn!("Took longer than expected");
}tracing requires tracing-subscriber (or another subscriber) to output events.
The Core Difference: Flat Events vs Structured Spans
log produces flat event records. tracing produces a tree of spans with events inside them.
With log, every log line is independent:
INFO 2026-04-24 14:23:01 - Processing request 42
WARN 2026-04-24 14:23:03 - Took longer than expected
INFO 2026-04-24 14:23:01 - Processing request 43When requests are interleaved in async code, understanding which log line belongs to which request requires including the ID in every single log message manually.
With tracing, context is attached to spans:
INFO request{user_id=42}: Processing request
WARN request{user_id=42}: Took longer than expected
INFO request{user_id=43}: Processing requestThe user_id context is attached to the span and automatically included in all events within that span, even nested function calls, without manually threading it through every function signature.
When Async Changes Everything
In synchronous code, log works fine. In async code, tracing is nearly essential.
Consider an async web server handling 1000 concurrent requests. With log:
// You must manually include request_id in EVERY log call
async fn handler(request_id: Uuid, ...) {
log::info!("request_id={}: handling request", request_id);
let result = some_async_operation(request_id).await;
log::info!("request_id={}: completed", request_id);
}
async fn some_async_operation(request_id: Uuid) {
log::debug!("request_id={}: starting operation", request_id); // must pass id down
}With tracing:
#[instrument(fields(request_id = %request_id))]
async fn handler(request_id: Uuid, ...) {
info!("handling request"); // request_id automatically included
some_async_operation().await;
}
#[instrument] // parent span context propagated automatically
async fn some_async_operation() {
debug!("starting operation"); // request_id context still present
}In async Rust, multiple tasks are interleaved on the same thread. The tracing crate maintains span context correctly across .await points. log cannot do this; context must be manually threaded.
3 spots open this month → Check if you are eligible.
We help experienced developers transition into Rust roles at €80K–€150K+ in Europe or $130K–$200K+ in the US.
Code Comparison: Full Setup
log + env_logger Setup
// Cargo.toml
// log = "0.4"
// env_logger = "0.11"
fn main() {
env_logger::init(); // reads RUST_LOG env var
log::info!("Application starting");
process_something();
}
fn process_something() {
log::debug!("Processing...");
log::error!("Something went wrong: {}", "error details");
}Run with: RUST_LOG=debug cargo run
tracing + tracing-subscriber Setup
// Cargo.toml
// tracing = "0.1"
// tracing-subscriber = { version = "0.3", features = ["env-filter"] }
fn main() {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
tracing::info!("Application starting");
process_something(42);
}
#[tracing::instrument]
fn process_something(id: u64) {
tracing::debug!("Processing...");
tracing::error!("Something went wrong");
// Both events automatically carry id=42 from the span
}Run with: RUST_LOG=debug cargo run
tracing for Async Web API (Production Pattern)
// In main.rs or setup
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
fn init_tracing() {
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()),
))
.with(tracing_subscriber::fmt::layer().json()) // JSON output for log aggregation
.init();
}
// In Axum:
let app = Router::new()
.route("/users/:id", get(get_user))
.layer(tower_http::trace::TraceLayer::new_for_http()); // Automatic request tracingEcosystem Comparison
| log | tracing | |
|---|---|---|
| Backend options | env_logger, fern, pretty_env_logger, slog | tracing-subscriber, tracing-appender |
| JSON output | via fern or custom | tracing-subscriber fmt().json() |
| OpenTelemetry | Not directly | tracing-opentelemetry |
| Jaeger / Zipkin | Not directly | tracing-opentelemetry + OTLP |
| Axum integration | Manual | tower-http::TraceLayer |
| Tokio integration | N/A | First-class (tracing was created by Tokio team) |
| Log filtering | RUST_LOG env var | EnvFilter (same syntax) |
| Async context | ❌ | ✅ |
| Library compatibility | ✅ Universal | ✅ (tracing has a log compatibility layer) |
Which Should You Use?
Use tracing When:
- Building a web service or API (Axum, Actix-web, any async framework)
- Running in an async context (anything with Tokio)
- You want OpenTelemetry integration for distributed tracing
- Production logs will be aggregated (JSON output to Datadog, Loki, CloudWatch)
- You want automatic request context propagation
Use log When:
- Writing a library crate (the convention is for libraries to use
log, nottracing: let consumers choose the backend) - Building a simple CLI tool where structured spans add no value
- You need maximum ecosystem compatibility (virtually every crate supports
log) - The application is synchronous and simple
The Good News: tracing Supports log
If you use tracing, you can enable the log compatibility layer:
tracing = { version = "0.1", features = ["log"] }This means any crate that uses log::info!() will have its events automatically captured by tracing-subscriber. You get unified logging even when your dependencies use log. This is why new projects can use tracing exclusively without losing output from log-based dependencies.
Performance Considerations
Both are zero-cost when disabled. tracing has small overhead per active span.
When a log level is disabled (e.g., debug logs in production):
- Both
logandtracingmacros compile down to a level check; if the level is not enabled, essentially zero overhead.
When enabled:
log: allocates a string per event (format! under the hood)tracing: creates a span record (small allocation) plus event records. The overhead is measurable in micro-benchmarks but negligible in I/O-bound services.
For CPU-bound hot loops where logging has measurable overhead: both should be disabled in the hot path, or use tracing's enabled!() macro to conditionally skip expensive event construction.
Frequently Asked Questions
Yes, and it is easy. tracing provides the same macros (info!, debug!, warn!, error!) so you can replace use log::info with use tracing::info with no other changes. Then add #[instrument] to functions where you want automatic span context. The migration is incremental.
Convention: libraries should use log. The reason: if a library uses tracing, it forces all users of the library to pull in the tracing crate. If it uses log, users can choose any backend. Many libraries now use tracing and accept this dependency, but log is more conservative. For a library targeting wide compatibility: log. For a library used in async services (like reqwest, which uses tracing): tracing is fine.
slog was an earlier attempt at structured logging in Rust. It is still used in some codebases but has fallen behind tracing in ecosystem support and community adoption. New projects should use tracing rather than slog.
OpenTelemetry is a standard for distributed tracing that tracks requests as they flow across multiple services. If you have multiple microservices and want to trace a request from the API gateway through service A to service B, you need distributed tracing. tracing-opentelemetry integrates Rust's tracing spans with the OpenTelemetry standard, enabling export to Jaeger, Zipkin, Datadog, or any OTLP-compatible backend. For a single-service application, you do not need OpenTelemetry; tracing-subscriber with JSON output is sufficient.
