How Axum and Poem compare on handlers, extractors, OpenAPI generation, middleware and ecosystem, with the same endpoint written in each.
Axum and Poem look alike at first. Both use async functions as handlers on Tokio, and both parse requests through typed extractors. Poem stands out for poem-openapi, which generates an OpenAPI document from annotated Rust types, and Axum stands out for its size and its use of Tower.
The main differences:
#[handler] attribute, while Axum handlers are plain async functions.Middleware trait with optional Tower compatibility.Axum's routing module maps paths and methods to handlers, and any Tower layer can wrap a route. 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(())
}
The Poem repository holds the core framework alongside poem-openapi and other companion crates. The same endpoint in Poem 3:
use poem::{
handler, http::StatusCode, listener::TcpListener, post, web::{Data, Json},
EndpointExt, Route, Server,
};
use serde::Deserialize;
use sqlx::PgPool;
#[derive(Deserialize)]
struct Signup {
email: String,
}
#[handler]
async fn create_user(pool: Data<&PgPool>, Json(req): Json<Signup>) -> StatusCode {
match sqlx::query("INSERT INTO users (email) VALUES ($1)")
.bind(&req.email)
.execute(pool.0)
.await
{
Ok(_) => StatusCode::CREATED,
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
let app = Route::new().at("/users", post(create_user)).data(pool);
Server::new(TcpListener::bind("0.0.0.0:3000")).run(app).await?;
Ok(())
}
The Poem API docs cover the Endpoint trait that every route and middleware builds on. With poem-openapi, the same route becomes a method on an #[OpenApi] impl, and the request and response types derive Object, which gives you an OpenAPI document and a Swagger UI without writing the spec by hand. A spec generated from types also gives a coding agent an exact contract to read before it changes an endpoint.
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 app declares a database and a nightly cron job, and registers the job's route at PURGE.path():
use axum::{extract::Path, http::StatusCode, routing::{get, post}, Router};
use encore::cron;
use encore::storage::postgres::Database;
static DB: Database = Database::new("users").migrations("./migrations");
static PURGE: cron::Job = cron::Job::new(
"purge-unverified",
"/internal/purge-unverified",
cron::Schedule::Cron("0 3 * * *"),
)
.title("Delete unverified signups");
async fn purge() -> StatusCode {
match DB
.execute("DELETE FROM users WHERE verified = false", &[])
.await
{
Ok(_) => StatusCode::OK,
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
async fn email(Path(id): Path<i64>) -> Result<String, StatusCode> {
let row = DB
.query_opt("SELECT email FROM users WHERE id = $1", &[&id])
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
Ok(row.get("email"))
}
#[tokio::main]
async fn main() {
encore::initialize();
let app = Router::new()
.route("/users/:id/email", get(email))
.route(PURGE.path(), post(purge));
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, so a Poem #[handler] can call DB.execute the same way and be mounted at PURGE.path() with Route::at.
encore run starts Postgres locally and applies the migrations, and the CLI reference covers encore test, where each test gets its own database.
A deploy provisions the database and schedules the job in your own AWS or GCP account.
Cloud access sits with Encore's IAM role, not the agent, so an agent that adds a cron job writes a declaration and never a scheduler config. The AI integration guide covers how to give an agent Encore's conventions.
Rust support is in beta, and you can request access.
Encore works with either when you want Postgres and cron jobs declared in Rust and provisioned in your own cloud account.
Axum is the more common choice, with the larger ecosystem and Tower middleware. Poem is worth a look when you want OpenAPI docs generated from your Rust types through poem-openapi.
Not by itself. Axum projects add a community crate such as utoipa or aide for OpenAPI. Poem has its own poem-openapi crate maintained in the same repository.
Poem has its own Endpoint and Middleware traits. It includes a tower-compat feature for using Tower layers and services, but its native middleware is not Tower.
Add the encore-dev crate, declare a cron::Job static with its schedule and register a POST route at JOB.path(). encore run starts Postgres locally, and a deploy schedules the job 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.