Skip to content

Split View: Rust로 웹 API 만들기: Axum과 Actix

|

Rust로 웹 API 만들기: Axum과 Actix

들어가며 — 왜 Rust로 웹 API인가

한때 웹 백엔드는 Rust와 거리가 멀어 보였습니다. 소유권과 수명 같은 개념이 요청-응답 코드의 진입 장벽처럼 느껴졌기 때문입니다. 하지만 이제 상황이 달라졌습니다. 앞선 글에서 본 async/await와 Tokio 위에 성숙한 웹 프레임워크들이 올라오면서, Rust로 웹 API를 만드는 경험이 놀랄 만큼 깔끔해졌습니다.

Rust 웹 백엔드의 매력은 분명합니다. 컴파일 타임에 잡히는 방대한 버그, 가비지 컬렉터 없이도 나오는 예측 가능한 저지연, 그리고 타입 시스템이 강제하는 견고함입니다. JSON 필드 이름 하나 오타 내면 컴파일이 거부하고, Option 이 널 처리를 강제합니다.

이 글은 현재 가장 인기 있는 프레임워크 Axum을 중심으로, 라우팅부터 데이터베이스 연동, 에러 처리까지 실전 흐름을 짚습니다. 마지막에는 또 다른 대표 주자인 Actix Web과 비교합니다. Axum은 Tokio 팀이 만들었고 tower 생태계와 매끄럽게 맞물린다는 점에서 좋은 출발점입니다.

Axum 첫 걸음 — 핸들러와 라우팅

Axum에서 가장 기본이 되는 두 개념은 **핸들러(handler)**와 **라우터(router)**입니다. 핸들러는 요청을 받아 응답을 돌려주는 async 함수이고, 라우터는 어떤 경로와 메서드가 어떤 핸들러로 가는지를 정의합니다.

use axum::{routing::get, Router};

#[tokio::main]
async fn main() {
    // 라우터: 경로와 핸들러를 연결
    let app = Router::new()
        .route("/", get(root))
        .route("/health", get(health));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();
    axum::serve(listener, app).await.unwrap();
}

// 핸들러: 그냥 async 함수다
async fn root() -> &'static str {
    "Hello, Axum!"
}

async fn health() -> &'static str {
    "OK"
}

핸들러가 그냥 async 함수라는 점이 Axum의 큰 장점입니다. 매크로로 감싸거나 특별한 트레이트를 구현할 필요 없이, 인자로 원하는 것을 받고 응답 가능한 값을 반환하면 됩니다. 반환 타입이 &'static str 인데도 자동으로 HTTP 응답이 되는 것은, Axum이 IntoResponse 라는 트레이트로 다양한 타입을 응답으로 변환해 주기 때문입니다. 문자열, 상태 코드, JSON, 튜플 등 많은 것이 그대로 응답이 됩니다.

추출기 — 요청에서 원하는 것 뽑아내기

Axum의 진짜 우아함은 **추출기(extractor)**에 있습니다. 핸들러의 인자에 특정 타입을 적으면, Axum이 요청에서 그 부분을 알아서 뽑아 넘겨줍니다. 경로 파라미터, 쿼리 문자열, JSON 본문, 헤더 등 대부분의 요청 요소에 추출기가 있습니다.

use axum::{
    extract::{Path, Query, Json},
    routing::{get, post},
    Router,
};
use serde::Deserialize;

#[derive(Deserialize)]
struct Pagination {
    page: u32,
    per_page: u32,
}

#[derive(Deserialize)]
struct CreateUser {
    name: String,
    email: String,
}

// 경로 파라미터 추출: /users/42 -> id = 42
async fn get_user(Path(id): Path<u64>) -> String {
    format!("user {id}")
}

// 쿼리 문자열 추출: /users?page=2&per_page=20
async fn list_users(Query(p): Query<Pagination>) -> String {
    format!("page {} ({} per page)", p.page, p.per_page)
}

// JSON 본문 추출
async fn create_user(Json(payload): Json<CreateUser>) -> String {
    format!("created {} <{}>", payload.name, payload.email)
}

fn app() -> Router {
    Router::new()
        .route("/users/{id}", get(get_user))
        .route("/users", get(list_users).post(create_user))
}

추출기의 순서에도 규칙이 있습니다. 요청 본문을 소비하는 추출기(예: Json)는 본문을 통째로 읽어 버리므로 하나만, 그리고 인자 목록의 마지막에 와야 합니다. 헤더나 경로처럼 본문을 소비하지 않는 추출기는 그 앞에 여럿 올 수 있습니다. 추출에 실패하면(예: JSON 파싱 실패) Axum이 자동으로 적절한 4xx 응답을 돌려주므로, 핸들러 본문은 성공 경로에만 집중할 수 있습니다.

공유 상태 — 데이터베이스 풀과 설정 나누기

실제 애플리케이션의 핸들러는 데이터베이스 연결 풀이나 설정 값 같은 공유 자원에 접근해야 합니다. Axum은 이를 **상태(state)**로 다루며, State 추출기로 핸들러에 주입합니다.

use axum::{extract::State, routing::get, Router};
use std::sync::Arc;

// 애플리케이션 전역에서 공유할 상태
#[derive(Clone)]
struct AppState {
    // 실제로는 여기에 sqlx 풀, 설정 등이 들어간다
    app_name: Arc<String>,
}

async fn handler(State(state): State<AppState>) -> String {
    format!("running: {}", state.app_name)
}

#[tokio::main]
async fn main() {
    let state = AppState {
        app_name: Arc::new("my-service".to_string()),
    };

    let app = Router::new()
        .route("/", get(handler))
        .with_state(state); // 라우터에 상태를 붙인다

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();
    axum::serve(listener, app).await.unwrap();
}

AppStateClone 을 요구하는 이유는, 요청마다 상태의 값 복제가 필요하기 때문입니다. 그래서 데이터베이스 풀처럼 실제 자원은 Arc 로 감싸 값싸게 복제되도록 하는 것이 관례입니다. 앞선 스마트 포인터 글에서 본 Arc 가 여기서 자연스럽게 쓰입니다. 풀 자체는 하나만 존재하고, 각 요청은 그 Arc 의 클론을 공유합니다.

tower 미들웨어 — 공통 관심사 얹기

로깅, 인증, 타임아웃, 압축, CORS처럼 여러 라우트에 공통으로 적용할 로직은 미들웨어로 다룹니다. Axum은 별도 미들웨어 시스템을 새로 만들지 않고, Rust 생태계의 표준 서비스 추상화인 tower를 그대로 씁니다. 덕분에 towertower-http 가 제공하는 방대한 미들웨어를 바로 얹을 수 있습니다.

use axum::{routing::get, Router};
use tower_http::trace::TraceLayer;
use tower_http::timeout::TimeoutLayer;
use std::time::Duration;

fn app() -> Router {
    Router::new()
        .route("/", get(|| async { "Hello" }))
        // 미들웨어는 layer로 얹는다
        .layer(TraceLayer::new_for_http())          // 요청 로깅/추적
        .layer(TimeoutLayer::new(Duration::from_secs(10))) // 요청 타임아웃
}

미들웨어를 layer 로 얹으면 그 아래 모든 라우트에 적용됩니다. 여러 layer를 쌓으면 바깥에서 안쪽으로 요청이 통과하고, 응답은 반대로 빠져나옵니다. tower를 공유하는 이 설계 덕분에, gRPC 프레임워크 tonic 등 tower 기반의 다른 도구들과 미들웨어를 재사용할 수 있다는 점이 Axum의 큰 강점입니다.

serde로 JSON 다루기 — 직렬화와 역직렬화

웹 API의 핵심은 JSON을 주고받는 일입니다. Rust에서 이것은 사실상 serde 크레이트로 처리합니다. 구조체에 Serialize/Deserialize 를 파생시키면, 그 타입과 JSON 사이의 변환을 컴파일러가 생성해 줍니다.

use axum::Json;
use serde::{Deserialize, Serialize};

// 요청 본문을 받을 타입
#[derive(Deserialize)]
struct CreateTodo {
    title: String,
    #[serde(default)] // 없으면 false
    done: bool,
}

// 응답으로 내보낼 타입
#[derive(Serialize)]
struct Todo {
    id: u64,
    title: String,
    done: bool,
}

async fn create_todo(Json(input): Json<CreateTodo>) -> Json<Todo> {
    let todo = Todo {
        id: 1,
        title: input.title,
        done: input.done,
    };
    Json(todo) // Json으로 감싸면 자동으로 JSON 응답이 된다
}

Json 추출기로 요청 본문을 타입으로 받고, 반환 시 Json 으로 감싸면 응답이 JSON으로 나갑니다. serde의 속성으로 세부를 제어할 수도 있습니다. #[serde(rename_all = "camelCase")] 로 필드 이름 규칙을 바꾸거나, #[serde(default)] 로 누락된 필드에 기본값을 주거나, Option<T> 필드로 있어도 되고 없어도 되는 값을 표현합니다. 이 모든 것이 타입에 선언적으로 붙으므로, JSON 계약이 코드에 그대로 드러납니다.

sqlx로 데이터베이스 연동 — 컴파일 타임에 검증되는 쿼리

데이터를 저장하고 조회하려면 데이터베이스가 필요합니다. Rust 비동기 생태계에서 널리 쓰이는 것이 sqlx입니다. sqlx의 독특한 점은, ORM이 아니라 날 SQL을 쓰되 그 쿼리를 컴파일 타임에 실제 데이터베이스 스키마와 대조해 검증한다는 것입니다. 오타 난 컬럼명이나 타입 불일치가 런타임이 아니라 컴파일 때 잡힙니다.

use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;

#[derive(sqlx::FromRow)]
struct User {
    id: i64,
    name: String,
    email: String,
}

async fn setup_pool() -> PgPool {
    PgPoolOptions::new()
        .max_connections(5)
        .connect("postgres://localhost/mydb")
        .await
        .unwrap()
}

async fn find_user(pool: &PgPool, id: i64) -> Result<Option<User>, sqlx::Error> {
    // query_as로 결과를 구조체에 매핑한다
    let user = sqlx::query_as::<_, User>(
        "SELECT id, name, email FROM users WHERE id = $1",
    )
    .bind(id)
    .fetch_optional(pool)
    .await?;

    Ok(user)
}

sqlx는 연결 풀을 제공하며, 이 풀을 앞서 본 State 로 애플리케이션에 공유합니다. 파라미터는 달러 기호에 번호를 붙인 자리표시자(첫 번째 파라미터, 두 번째 파라미터 식)와 bind 로 넘겨 SQL 인젝션을 원천 차단합니다. 결과 행은 FromRow 를 파생시킨 구조체로 매핑됩니다. fetch_optional 은 결과가 없을 수 있음을 Option 으로 표현하고, 여러 행이면 fetch_all, 정확히 한 행을 기대하면 fetch_one 을 씁니다. 더 나아가 query! 매크로를 쓰면 컴파일 타임에 실제 DB에 접속해 쿼리 자체를 검증하는 강력한 모드도 있습니다.

에러 처리 — Result를 응답으로

지금까지의 예제는 대부분 성공 경로만 다뤘지만, 실제 API는 실패를 우아하게 다뤄야 합니다. Rust의 관용구는 핸들러가 Result 를 반환하고, 에러 타입이 스스로 HTTP 응답으로 변환되게 하는 것입니다. IntoResponse 를 에러 타입에 구현하면 됩니다.

use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;

// 애플리케이션 전용 에러 타입
enum AppError {
    NotFound,
    Database(sqlx::Error),
}

// 에러를 HTTP 응답으로 변환하는 방법을 정의
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AppError::NotFound => (StatusCode::NOT_FOUND, "not found"),
            AppError::Database(_) => {
                (StatusCode::INTERNAL_SERVER_ERROR, "internal error")
            }
        };
        (status, Json(json!({ "error": message }))).into_response()
    }
}

// sqlx 에러를 AppError로 자동 변환 (? 연산자용)
impl From<sqlx::Error> for AppError {
    fn from(e: sqlx::Error) -> Self {
        AppError::Database(e)
    }
}

async fn get_user(
    State(pool): State<sqlx::PgPool>,
    Path(id): Path<i64>,
) -> Result<Json<String>, AppError> {
    let user = find_user(&pool, id).await?; // sqlx::Error가 ?로 AppError가 된다
    match user {
        Some(_) => Ok(Json(format!("user {id}"))),
        None => Err(AppError::NotFound), // 404로 변환된다
    }
}

이 패턴의 아름다움은 ? 연산자와의 조합에 있습니다. From<sqlx::Error> 를 구현해 두면, 데이터베이스 호출 뒤에 ? 만 붙여도 실패가 자동으로 AppError 로 바뀌고, 그것이 다시 적절한 HTTP 상태 코드로 변환됩니다. 핸들러 본문은 성공 흐름을 자연스럽게 쓰고, 에러는 조용히 위로 전파되어 일관된 형태의 응답이 됩니다. 실무에서는 이 부분을 thiserror 같은 크레이트로 더 간결하게 다듬습니다.

Actix Web과의 비교 — 무엇을 고를까

Axum 외에 또 하나의 대표 프레임워크가 Actix Web입니다. 오래 전부터 성숙했고, 각종 벤치마크에서 최상위권 성능으로 이름을 알려 왔습니다. 두 프레임워크는 실제로 상당히 닮았습니다. 둘 다 async/await 위에서 돌고, 추출기 스타일의 핸들러를 쓰며, serde로 JSON을 다룹니다.

핵심적인 차이는 다음과 같습니다.

  • 미들웨어 생태계: Axum은 tower를 그대로 채택해, tower/tower-http의 미들웨어와 tonic 같은 다른 tower 기반 도구를 공유합니다. Actix는 자체 미들웨어 시스템을 가집니다.
  • 런타임: Axum은 Tokio에 직결됩니다. Actix는 자체 액터 런타임(actix-rt, 내부적으로 Tokio 기반) 위에서 동작해 온 역사가 있습니다.
  • 설계 철학: Actix는 이름대로 액터 모델에서 출발했고, 지금도 상태 관리에 그 색채가 남아 있습니다. Axum은 함수와 tower 서비스라는 더 얇은 추상화를 지향합니다.
  • 팀과 통합: Axum은 Tokio 팀이 관리하므로 Tokio·hyper·tower와의 통합이 매끄럽습니다.

간단한 비교를 위해 Actix의 핸들러도 한 번 보겠습니다. 모양이 Axum과 크게 다르지 않다는 걸 알 수 있습니다.

use actix_web::{get, web, App, HttpServer, Responder};

#[get("/users/{id}")]
async fn get_user(path: web::Path<u64>) -> impl Responder {
    let id = path.into_inner();
    format!("user {id}")
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| App::new().service(get_user))
        .bind(("0.0.0.0", 3000))?
        .run()
        .await
}

선택 기준을 요약하면 이렇습니다. tower 생태계와의 통합, Tokio와의 밀접함, 얇고 예측 가능한 추상화를 중시한다면 Axum이 요즘의 자연스러운 기본값입니다. 오래 검증된 성숙함과 풍부한 기능, 그리고 최고 수준의 벤치마크 성능을 원한다면 Actix Web도 여전히 훌륭한 선택입니다. 다만 둘의 실질적 성능 차이는 대부분의 애플리케이션에서 무시할 만하므로, 생태계 적합성과 팀의 취향으로 고르는 편이 현실적입니다.

마치며

Rust로 웹 API를 만드는 일은 이제 특별한 고행이 아니라, 오히려 즐거운 경험에 가깝습니다. 이 글에서 짚은 흐름을 정리하면 다음과 같습니다.

  • Axum의 핸들러는 그냥 async 함수이고, 추출기로 요청에서 원하는 것을 선언적으로 뽑습니다.
  • 데이터베이스 풀 같은 공유 자원은 Arc 로 감싸 **상태(State)**로 주입합니다.
  • 로깅·타임아웃 같은 공통 관심사는 tower 미들웨어로 얹습니다.
  • serde로 JSON을, sqlx로 컴파일 타임에 검증되는 SQL을 다룹니다.
  • 에러는 IntoResponse 를 구현한 타입 + ? 연산자로 우아하게 응답으로 변환합니다.
  • Actix Web은 성숙하고 강력한 대안이며, 선택은 성능보다 생태계 적합성으로 갈리는 경우가 많습니다.

타입 시스템이 계약을 강제하고, 컴파일러가 방대한 버그를 미리 잡아 주는 이 환경에서, 여러분의 API는 처음부터 견고합니다. Rust가 주는 안전성과 성능은 시스템 프로그래밍만의 이야기가 아니라, 웹 백엔드에서도 그대로 유효합니다.

참고 자료

Building Web APIs in Rust: Axum and Actix

Introduction — Why Web APIs in Rust?

Web backends once seemed far removed from Rust. Concepts like ownership and lifetimes felt like a barrier to entry for request-response code. But things have changed. With mature web frameworks built on the async/await and Tokio we saw in earlier posts, building web APIs in Rust has become surprisingly clean.

The appeal of a Rust web backend is clear: a vast class of bugs caught at compile time, predictable low latency without a garbage collector, and the robustness the type system enforces. Misspell a JSON field name and the compiler rejects it; Option forces you to handle nulls.

This post centers on Axum, currently the most popular framework, and walks through the practical flow from routing to database access to error handling. At the end we compare it with the other major player, Actix Web. Axum is a good starting point because it was built by the Tokio team and meshes smoothly with the tower ecosystem.

First Steps in Axum — Handlers and Routing

The two most fundamental concepts in Axum are handlers and routers. A handler is an async function that takes a request and returns a response; a router defines which path and method go to which handler.

use axum::{routing::get, Router};

#[tokio::main]
async fn main() {
    // router: wire paths to handlers
    let app = Router::new()
        .route("/", get(root))
        .route("/health", get(health));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();
    axum::serve(listener, app).await.unwrap();
}

// handler: just an async function
async fn root() -> &'static str {
    "Hello, Axum!"
}

async fn health() -> &'static str {
    "OK"
}

A big advantage of Axum is that a handler is just an async function. There's no macro to wrap it in, no special trait to implement — you take whatever you want as arguments and return something that can be a response. A return type of &'static str becomes an HTTP response automatically because Axum uses a trait called IntoResponse to turn many types into responses: strings, status codes, JSON, tuples, and more all become responses directly.

Extractors — Pulling What You Want Out of a Request

Axum's real elegance is in extractors. Write a particular type as a handler argument, and Axum pulls that piece out of the request and hands it to you. Path parameters, query strings, JSON bodies, headers — most request elements have an extractor.

use axum::{
    extract::{Path, Query, Json},
    routing::{get, post},
    Router,
};
use serde::Deserialize;

#[derive(Deserialize)]
struct Pagination {
    page: u32,
    per_page: u32,
}

#[derive(Deserialize)]
struct CreateUser {
    name: String,
    email: String,
}

// path parameter: /users/42 -> id = 42
async fn get_user(Path(id): Path<u64>) -> String {
    format!("user {id}")
}

// query string: /users?page=2&per_page=20
async fn list_users(Query(p): Query<Pagination>) -> String {
    format!("page {} ({} per page)", p.page, p.per_page)
}

// JSON body
async fn create_user(Json(payload): Json<CreateUser>) -> String {
    format!("created {} <{}>", payload.name, payload.email)
}

fn app() -> Router {
    Router::new()
        .route("/users/{id}", get(get_user))
        .route("/users", get(list_users).post(create_user))
}

There's a rule to extractor ordering. An extractor that consumes the request body (like Json) reads the whole body, so there can be only one, and it must come last in the argument list. Extractors that don't consume the body — headers, paths — can appear multiple times before it. If extraction fails (say, JSON parsing fails), Axum automatically returns an appropriate 4xx response, so your handler body can focus only on the success path.

Shared State — Sharing a Database Pool and Config

A real application's handlers need access to shared resources like a database connection pool or config values. Axum handles this as state, injected into handlers through the State extractor.

use axum::{extract::State, routing::get, Router};
use std::sync::Arc;

// state shared across the whole application
#[derive(Clone)]
struct AppState {
    // in reality this holds a sqlx pool, config, etc.
    app_name: Arc<String>,
}

async fn handler(State(state): State<AppState>) -> String {
    format!("running: {}", state.app_name)
}

#[tokio::main]
async fn main() {
    let state = AppState {
        app_name: Arc::new("my-service".to_string()),
    };

    let app = Router::new()
        .route("/", get(handler))
        .with_state(state); // attach state to the router

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();
    axum::serve(listener, app).await.unwrap();
}

The reason AppState must be Clone is that the state's value is cloned per request. So the convention is to wrap real resources, like a database pool, in an Arc so they clone cheaply. The Arc from the earlier smart-pointers post is used naturally here: only one pool actually exists, and each request shares a clone of that Arc.

tower Middleware — Layering Cross-Cutting Concerns

Logic applied in common across many routes — logging, authentication, timeouts, compression, CORS — is handled as middleware. Rather than inventing a fresh middleware system, Axum uses tower, the standard service abstraction of the Rust ecosystem, directly. That lets you layer on the vast collection of middleware from tower and tower-http right away.

use axum::{routing::get, Router};
use tower_http::trace::TraceLayer;
use tower_http::timeout::TimeoutLayer;
use std::time::Duration;

fn app() -> Router {
    Router::new()
        .route("/", get(|| async { "Hello" }))
        // middleware is applied with layer
        .layer(TraceLayer::new_for_http())          // request logging/tracing
        .layer(TimeoutLayer::new(Duration::from_secs(10))) // request timeout
}

Applying middleware with layer applies it to all routes beneath it. Stack several layers and requests pass from the outside in, while responses flow back out the other way. Thanks to this shared-tower design, a big strength of Axum is that you can reuse middleware with other tower-based tools, such as the gRPC framework tonic.

JSON with serde — Serialization and Deserialization

The core of a web API is exchanging JSON. In Rust this is essentially handled by the serde crate. Derive Serialize/Deserialize on a struct and the compiler generates the conversion between that type and JSON.

use axum::Json;
use serde::{Deserialize, Serialize};

// type for the incoming request body
#[derive(Deserialize)]
struct CreateTodo {
    title: String,
    #[serde(default)] // defaults to false if absent
    done: bool,
}

// type to send in the response
#[derive(Serialize)]
struct Todo {
    id: u64,
    title: String,
    done: bool,
}

async fn create_todo(Json(input): Json<CreateTodo>) -> Json<Todo> {
    let todo = Todo {
        id: 1,
        title: input.title,
        done: input.done,
    };
    Json(todo) // wrapping in Json makes it a JSON response automatically
}

The Json extractor takes the request body as a type, and wrapping the return in Json sends the response as JSON. serde's attributes let you control the details: #[serde(rename_all = "camelCase")] changes the field-naming convention, #[serde(default)] supplies a default for a missing field, and an Option<T> field expresses a value that may or may not be present. All of this attaches declaratively to the type, so the JSON contract is right there in the code.

Database Access with sqlx — Queries Verified at Compile Time

To store and retrieve data you need a database. A widely used choice in the async Rust ecosystem is sqlx. What's distinctive about sqlx is that it's not an ORM — you write raw SQL, but it checks that SQL against your real database schema at compile time. A misspelled column name or a type mismatch is caught at compile time, not at run time.

use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;

#[derive(sqlx::FromRow)]
struct User {
    id: i64,
    name: String,
    email: String,
}

async fn setup_pool() -> PgPool {
    PgPoolOptions::new()
        .max_connections(5)
        .connect("postgres://localhost/mydb")
        .await
        .unwrap()
}

async fn find_user(pool: &PgPool, id: i64) -> Result<Option<User>, sqlx::Error> {
    // query_as maps the result into a struct
    let user = sqlx::query_as::<_, User>(
        "SELECT id, name, email FROM users WHERE id = $1",
    )
    .bind(id)
    .fetch_optional(pool)
    .await?;

    Ok(user)
}

sqlx provides a connection pool, which you share into the application via the State we saw earlier. Parameters are passed through numbered placeholders (a dollar sign followed by a position number) together with bind, which shuts the door on SQL injection entirely. Result rows are mapped into a struct that derives FromRow. fetch_optional expresses "there may be no result" as an Option; use fetch_all for many rows, and fetch_one when you expect exactly one. Going further, the query! macro offers a powerful mode that connects to the real DB at compile time and verifies the query itself.

Error Handling — Turning a Result into a Response

Most of the examples so far dealt only with the success path, but a real API must handle failure gracefully. The Rust idiom is for a handler to return a Result and have the error type convert itself into an HTTP response. You do that by implementing IntoResponse on the error type.

use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;

// application-specific error type
enum AppError {
    NotFound,
    Database(sqlx::Error),
}

// define how the error becomes an HTTP response
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AppError::NotFound => (StatusCode::NOT_FOUND, "not found"),
            AppError::Database(_) => {
                (StatusCode::INTERNAL_SERVER_ERROR, "internal error")
            }
        };
        (status, Json(json!({ "error": message }))).into_response()
    }
}

// auto-convert a sqlx error into AppError (for the ? operator)
impl From<sqlx::Error> for AppError {
    fn from(e: sqlx::Error) -> Self {
        AppError::Database(e)
    }
}

async fn get_user(
    State(pool): State<sqlx::PgPool>,
    Path(id): Path<i64>,
) -> Result<Json<String>, AppError> {
    let user = find_user(&pool, id).await?; // sqlx::Error becomes AppError via ?
    match user {
        Some(_) => Ok(Json(format!("user {id}"))),
        None => Err(AppError::NotFound), // converts to a 404
    }
}

The beauty of this pattern is how it combines with the ? operator. Once you implement From<sqlx::Error>, just appending ? after a database call turns a failure into an AppError automatically, which in turn becomes an appropriate HTTP status code. The handler body reads naturally along the success flow, and errors propagate quietly upward into a consistently shaped response. In practice you'd polish this with a crate like thiserror to make it even more concise.

A Comparison with Actix Web — Which to Choose

Beyond Axum, the other major framework is Actix Web. It has been mature for a long time and made its name with top-tier performance across various benchmarks. The two frameworks are in fact quite similar: both run on async/await, use extractor-style handlers, and handle JSON with serde.

The key differences are these:

  • Middleware ecosystem: Axum adopts tower directly, sharing middleware from tower/tower-http and with other tower-based tools like tonic. Actix has its own middleware system.
  • Runtime: Axum is tied directly to Tokio. Actix has historically run on its own actor runtime (actix-rt, itself Tokio-based).
  • Design philosophy: Actix started, as its name suggests, from the actor model, and traces of that remain in its state management. Axum aims at a thinner abstraction of functions and tower services.
  • Team and integration: Axum is maintained by the Tokio team, so its integration with Tokio, hyper, and tower is seamless.

For an easy comparison, here's an Actix handler too. You can see the shape isn't very different from Axum.

use actix_web::{get, web, App, HttpServer, Responder};

#[get("/users/{id}")]
async fn get_user(path: web::Path<u64>) -> impl Responder {
    let id = path.into_inner();
    format!("user {id}")
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| App::new().service(get_user))
        .bind(("0.0.0.0", 3000))?
        .run()
        .await
}

To summarize the decision: if you value integration with the tower ecosystem, closeness to Tokio, and a thin, predictable abstraction, Axum is the natural default today. If you want long-proven maturity, rich features, and top-of-the-charts benchmark numbers, Actix Web is still an excellent choice. That said, the practical performance difference between the two is negligible for most applications, so it's realistic to choose on ecosystem fit and team preference.

Wrapping Up

Building web APIs in Rust is no longer a special ordeal — it's closer to a pleasant experience. To recap the flow this post covered:

  • An Axum handler is just an async function, and extractors pull what you want from a request declaratively.
  • Shared resources like a database pool are wrapped in Arc and injected as state.
  • Cross-cutting concerns like logging and timeouts are layered on as tower middleware.
  • serde handles JSON; sqlx handles SQL verified at compile time.
  • Errors convert gracefully into responses via a type implementing IntoResponse plus the ? operator.
  • Actix Web is a mature, powerful alternative, and the choice often comes down to ecosystem fit rather than performance.

In an environment where the type system enforces the contract and the compiler catches a vast class of bugs up front, your API is robust from day one. The safety and performance Rust offers aren't only a systems-programming story — they hold just as well in a web backend.

References