Oct 23, 20265 min read

Axum vs Warp

How Axum and Warp compare on routing, filters and extractors, middleware, error handling and compile times, with the same endpoint written in each.

Axum and Warp both run on Tokio and hyper, and both come from people close to those projects. Warp, written by hyper's author, builds every route out of composable filters. Axum, from the Tokio team, uses plain async functions and extractors on top of Tower. The two can express the same API, and the difference is how that code reads and how the compiler reports mistakes.

The main differences:

  • Warp composes routes from Filter values with combinators such as and and map, while Axum registers async functions on a Router.
  • Warp handles errors through rejections that a recover filter turns into responses, while Axum handlers return any type that implements IntoResponse.
  • Axum middleware is Tower layers, while Warp wraps filters with its own helpers such as with and wrap_fn.
  • Axum has the larger user base by a wide margin, and more crates target it.

Axum vs Warp at a glance

CapabilityEncoreAxumWarp
RoleInfrastructure SDK added to either frameworkWeb frameworkWeb framework
Route declarationFrom the frameworkRouter::new().route(...)Filters combined with and and or
Request parsingFrom the frameworkExtractorsFilters such as warp::body::json()
Shared stateDeclared as a staticStateA filter that clones the value
ErrorsFrom the frameworkIntoResponse return typesRejections and recover
MiddlewareFrom the frameworkTower layersWrapping filters

Axum: functions on a router

Axum's middleware documentation shows how any Tower layer applies to a router or a single route, which is how tracing and timeouts usually get added. The signup endpoint in Axum 0.7:

use axum::{extract::State, http::StatusCode, routing::post, Json, Router}; use serde::Deserialize; use sqlx::PgPool; #[derive(Deserialize)] struct Signup { email: String, } async fn create_user( State(pool): State<PgPool>, Json(req): Json<Signup>, ) -> Result<StatusCode, StatusCode> { sqlx::query("INSERT INTO users (email) VALUES ($1)") .bind(&req.email) .execute(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(StatusCode::CREATED) } #[tokio::main] async fn main() -> anyhow::Result<()> { let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?; let app = Router::new() .route("/users", post(create_user)) .with_state(pool); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; axum::serve(listener, app).await?; Ok(()) }

Warp: everything is a filter

The Warp README describes the Filter as the fundamental building block, covering path routing, headers, query strings and bodies. The same endpoint with Warp 0.4, where the pool reaches the handler through a filter:

use serde::Deserialize; use sqlx::PgPool; use warp::{http::StatusCode, Filter}; #[derive(Deserialize)] struct Signup { email: String, } async fn create_user(req: Signup, pool: PgPool) -> Result<StatusCode, warp::Rejection> { let status = match sqlx::query("INSERT INTO users (email) VALUES ($1)") .bind(&req.email) .execute(&pool) .await { Ok(_) => StatusCode::CREATED, Err(_) => StatusCode::INTERNAL_SERVER_ERROR, }; Ok(status) } #[tokio::main] async fn main() -> anyhow::Result<()> { let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?; let with_pool = warp::any().map(move || pool.clone()); let users = warp::post() .and(warp::path("users")) .and(warp::path::end()) .and(warp::body::json()) .and(with_pool) .and_then(create_user); warp::serve(users).run(([0, 0, 0, 0], 3030)).await; Ok(()) }

Since 0.4, the server is behind a feature flag, so Cargo.toml needs warp = { version = "0.4", features = ["server"] }. The warp API docs list the built-in filters.

Compile times and errors

Filter chains produce deeply nested types. A long route tree can slow compilation, and a type mismatch prints the full nested type in the error. Axum avoids most of this because each handler is a separate function. The Rust async book is a good background read on why both frameworks rely so heavily on futures and generic types.

Adding infrastructure to either framework with Encore

Encore combines the Rust service you have with infrastructure declared as static values. It works through an infrastructure SDK added as the encore-dev crate, and the framework keeps routing and parsing. Postgres databases, Pub/Sub topics, object storage buckets and cron jobs become declarations that Encore discovers and provisions.

This Axum 0.7 handler stores an avatar in a declared bucket and records the key in a declared database. The avatar_key column comes from a migration in ./migrations:

use axum::{body::Bytes, extract::Path, http::StatusCode, routing::put, Router}; use encore::objects::Bucket; use encore::storage::postgres::Database; static DB: Database = Database::new("users").migrations("./migrations"); static AVATARS: Bucket = Bucket::new("avatars"); async fn upload_avatar(Path(id): Path<i64>, body: Bytes) -> StatusCode { let key = format!("{id}.png"); if AVATARS.upload(&key, body.to_vec()).await.is_err() { return StatusCode::INTERNAL_SERVER_ERROR; } match DB .execute("UPDATE users SET avatar_key = $1 WHERE id = $2", &[&key, &id]) .await { Ok(_) => StatusCode::NO_CONTENT, Err(_) => StatusCode::INTERNAL_SERVER_ERROR, } } #[tokio::main] async fn main() { encore::initialize(); let app = Router::new().route("/users/:id/avatar", put(upload_avatar)); let addr = std::env::var("ENCORE_LISTEN_ADDR").unwrap_or("0.0.0.0:8080".into()); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); axum::serve(listener, app) .with_graceful_shutdown(encore::shutdown::signal()) .await .unwrap(); }

The statics do not depend on the router. A Warp handler can call AVATARS.upload and DB.execute the same way, with no with_pool filter needed.

encore run starts Postgres locally and serves the bucket through a local emulator, and the CLI reference covers encore test, where each test gets its own database.

A deploy provisions the database and bucket in your own AWS or GCP account.

Cloud access sits with Encore's IAM role, not the agent, so an agent that adds the bucket writes a declaration and never an IAM policy. The AI integration guide covers how to give an agent Encore's conventions.

Rust support is in beta, and you can request access.

Try Encore →

How to choose

  • Axum for most new APIs, especially when you want Tower middleware and simple handler signatures.
  • Warp when you prefer filter composition or already maintain a Warp codebase.

Encore works with either when you want Postgres and buckets declared in Rust and provisioned in your own cloud account.

Frequently asked questions

Should I use Axum or Warp?

Axum is the more common choice for new APIs, with Tower middleware and a larger user base. Warp suits teams that like composing routes from filters and already run it in production.

Is Warp still maintained?

Yes. Warp 0.4.0 was published in August 2025 and moved to hyper 1.0, and 0.4.3 followed in May 2026. The server now sits behind a server feature flag, so warp::serve needs features = ["server"] in Cargo.toml.

Why are Warp compile errors hard to read?

Each filter combinator wraps the previous type, so a route built from many filters has a deeply nested type. A mismatch produces an error that prints that whole type.

How do I add object storage to an Axum app?

Add the encore-dev crate, declare a Bucket::new static next to your routes and call upload and download from handlers. encore run serves the bucket locally, and a deploy provisions it as S3 or Google Cloud Storage in your own AWS or GCP account.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders