Building Data CLIs & Services
Ship fast ETL as clap-driven CLIs and Tokio/Axum services with observability and clear failure modes.
Recipe
Quick-reference recipe card - copy-paste ready.
use anyhow::{Context, Result};
use clap::Parser;
use polars::prelude::*;
use tracing::info;
#[derive(Parser)]
#[command(name = "csv2pq", about = "Convert CSV to Parquet with optional filter")]
struct Cli {
#[arg(long)]
input: String,
#[arg(long)]
output: String,
#[arg(long, default_value_t = 0)]
min_amount: i64,
}
fn main() -> Result<()> {
tracing_subscriber::fmt::init();
let cli = Cli::parse();
run(&cli).context("etl failed")?;
Ok(())
}
fn run(cli: &Cli) -> PolarsResult<()> {
info!(input = %cli.input, "starting");
let mut df = CsvReadOptions::default()
.try_into_reader_with_file_path(Some(cli.input.clone().into()))?
.finish()?;
if cli.min_amount > 0 {
df = df.lazy().filter(col("amount").gt(lit(cli.min_amount))).collect()?;
}
ParquetWriter::new(std::fs::File::create(&cli.output)?).finish(&mut df)?;
info!(rows = df.height(), "done");
Ok(())
}When to reach for this:
- Replacing brittle bash + Python scripts in cron
- Internal data platform tools with consistent
--help - Lightweight HTTP ingest/query layers in front of Parquet lakes
- Edge ETL on machines without a Python runtime
Working Example
use axum::{extract::Query, routing::get, Json, Router};
use polars::prelude::*;
use serde::Deserialize;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Clone)]
struct AppState {
df: Arc<RwLock<DataFrame>>,
}
#[derive(Deserialize)]
struct Params {
region: String,
}
async fn summary(
Query(p): Query<Params>,
axum::extract::State(state): axum::extract::State<AppState>,
) -> Result<Json<serde_json::Value>, String> {
let df = state.df.read().await;
let filtered = df
.clone()
.lazy()
.filter(col("region").eq(lit(p.region.clone())))
.select([col("amount").sum().alias("total")])
.collect()
.map_err(|e| e.to_string())?;
let total: i64 = filtered.column("total").unwrap().get(0).unwrap().try_extract().unwrap();
Ok(Json(serde_json::json!({ "region": p.region, "total": total })))
}
#[tokio::main]
async fn main() -> PolarsResult<()> {
let df = CsvReadOptions::default()
.try_into_reader_with_file_path(Some("sales.csv".into()))?
.finish()?;
let state = AppState { df: Arc::new(RwLock::new(df)) };
let app = Router::new().route("/summary", get(summary)).with_state(state);
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
Ok(())
}What this demonstrates:
- CLI pattern with
anyhowcontext andtracinglogs - Axum handler querying an in-memory Polars frame
- Shared state with
Arc<RwLock<_>>for reloadable datasets
Deep Dive
How It Works
- CLIs parse argv once, run synchronously or block on async with
#[tokio::main]. - Services keep hot data in memory or mmap Parquet for repeated queries.
- Long transforms belong in batch CLIs; services expose summaries or pre-aggregates.
- Structured logs (
tracing) correlate stage timings with input paths and row counts.
CLI vs Service
| Shape | Best for | Ops note |
|---|---|---|
| CLI | Cron, one-off transforms | Version binary in artifact store |
| Axum service | Low-QPS internal APIs | Add auth, timeouts, payload limits |
Rust Notes
// Distinguish exit codes in CLI main:
// std::process::exit(2) for bad args, 1 for data errors - helps orchestrators retry correctly.Gotchas
- Loading full dataset per HTTP request - latency and memory spikes. Fix: keep shared snapshot or query Parquet lazily.
- No stdin/stdout progress - operators think job hung on long CSV. Fix: log every N chunks with
tracingor indicatif. - Panics in library code - aborts CLI without context. Fix: return
Resultthroughmainand map to exit code. - Unauthenticated data endpoints - internal tools leak on VPN misconfig. Fix: mTLS or token middleware from day one.
- Writing partial Parquet on crash - corrupt files downstream. Fix: write to temp path then atomic rename.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Python + Click | Team only knows Python | Single-binary deploy requirement |
| Airflow orchestrating Rust CLI | Complex DAG dependencies | Simple nightly one-step job |
| gRPC + Arrow Flight | High-throughput service mesh | Quick internal JSON API |
| DuckDB CLI | SQL ad hoc on files | Custom Rust transforms in the same job |
FAQs
Should ETL be sync or async?
File-heavy CLIs are often sync Polars with optional tokio only for services. Do not async-wrap CPU-bound collect without spawn_blocking.
How do I reload data in a service?
Swap Arc<DataFrame> on a timer or S3 event, or memory-map Parquet for OS cache benefits.
What logging crate?
Use tracing with tracing-subscriber JSON in production; pair spans per pipeline stage.
How do I package the CLI?
cargo build --release plus optional cargo deb or cross-compiled musl binaries - see deployment-ops section for distro patterns.
Can clap read env vars?
Yes - #[arg(long, env = "INPUT_PATH")] helps containers without long argv lists.
How do I test CLIs?
Use assert_cmd to run the binary with temp fixture CSVs and assert output Parquet schema.
Should services return Arrow or JSON?
JSON for humans and small summaries; Arrow IPC for machine clients pulling large result sets.
How do I handle secrets?
Read S3 credentials from env or IAM roles - never embed keys in the binary.
What about config files?
Serde TOML for pipeline definitions; CLI flags override file defaults for one-off runs.
How does this connect to DataFusion?
Embed SQL in the service layer - see DataFusion for query handlers.
Related
- Data Basics - clap CSV example
- Streaming & Large Data - chunked CLI pipelines
- CSV/Parquet I/O - formats
- Polars - transform core
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.