The missing manual problem
You spend three days building a REST API in Rust. The routes work. The JSON serializes correctly. The tests pass. Then a frontend developer asks for the endpoint list, the expected request bodies, and the error codes. You stare at your codebase. You realize you never wrote a manual. You start typing out a markdown file, but you know it will be outdated by tomorrow.
OpenAPI solves this by turning your code into a living contract. Instead of maintaining a separate document, you annotate your handlers and data types. A tool reads those annotations and generates a machine-readable specification. Swagger UI reads that specification and renders an interactive playground. Your documentation stays accurate because it is derived directly from your source code.
What OpenAPI actually does
OpenAPI is a structured description of your HTTP interface. It lists every route, the HTTP methods they accept, the parameters they expect, and the shapes of their responses. Think of it like a restaurant menu. The kitchen knows exactly what it can cook. The menu tells customers what they can order, what ingredients go in, and what they get back. If the kitchen stops serving a dish, the menu gets updated. If the menu is wrong, customers get confused.
utoipa is the translator that reads your Rust types and function signatures and prints that menu automatically. It does not use runtime reflection. Rust does not expose type information at runtime in a way that would support this. Instead, utoipa uses procedural macros that run at compile time. They inspect your code, generate Rust structs that implement utoipa::ToSchema, and bundle everything into a JSON specification. When you hit a documentation endpoint, the server serves that JSON. Swagger UI renders it. No runtime overhead. No hidden allocations.
A minimal utoipa setup
You need three pieces to get started. The utoipa crate for the macros and schema generation. The axum crate for routing. And a small amount of boilerplate to wire them together.
[dependencies]
axum = "0.7"
utoipa = { version = "4", features = ["axum_extras"] }
serde = { version = "1", features = ["derive"] }
use axum::{routing::get, Json, Router};
use utoipa::OpenApi;
use utoipa::openapi::Info;
use utoipa::JsonSchema;
// Define the data shape the API will return.
// Deriving JsonSchema tells utoipa how to describe it.
#[derive(JsonSchema, serde::Serialize)]
struct Greeting {
message: String,
}
// Annotate the handler so utoipa knows about the route.
// The path attribute maps directly to HTTP method and URL.
#[utoipa::path(
get,
path = "/greet",
responses(
(status = 200, description = "Successful greeting", body = Greeting)
)
)]
// The handler signature matches standard axum conventions.
// Returning Json<Greeting> satisfies the response type.
async fn greet() -> Json<Greeting> {
Json(Greeting {
message: "Hello from Rust".to_string(),
})
}
// Collect all annotated routes into a single API spec.
// The derive macro generates the OpenAPI JSON structure.
#[derive(OpenApi)]
#[openapi(info(
title = "Greeting API",
version = "0.1.0",
description = "A minimal example showing utoipa integration"
))]
struct ApiDoc;
#[tokio::main]
async fn main() {
// Mount the documentation endpoint alongside your routes.
// utoipa provides a ready-made handler for the JSON spec.
let app = Router::new()
.route("/greet", get(greet))
.merge(utoipa::openapi::OpenApi::path("/api-doc/openapi.json"));
// Serve the app. Swagger UI can point to /api-doc/openapi.json.
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Run the server and visit http://127.0.0.1:3000/api-doc/openapi.json. You will see a structured JSON document describing your /greet endpoint. Point Swagger UI or Redoc to that URL and you get a fully interactive interface. The spec is generated at compile time. Changing a field in Greeting automatically updates the documentation on the next build.
How the compiler builds the spec
The magic happens during compilation. When you write #[utoipa::path(...)], the macro expands into a hidden struct that implements utoipa::ToSchema. It registers the route metadata, the expected parameters, and the response types. When you derive #[derive(OpenApi)], another macro scans your crate for all registered routes and schemas. It assembles them into a single utoipa::openapi::OpenApi struct.
This process is purely compile-time. The generated code lives in your binary. When a request hits the documentation endpoint, utoipa serializes that struct to JSON. There is no reflection. No trait object dispatch. No hidden heap allocations during request handling. The performance cost is zero at runtime. The tradeoff is that you must annotate your code explicitly. The compiler will not guess what you want documented.
Convention aside: the Rust community treats utoipa annotations as part of the API contract. If a route exists, it gets a #[utoipa::path]. If a type is exposed, it gets #[derive(JsonSchema)]. Missing annotations are treated as technical debt. Reviewers will ask for them during code review.
Real-world routing and models
Real APIs handle more than static strings. They accept query parameters, path variables, request bodies, and multiple response variants. utoipa handles all of these through attribute arguments and type inference.
use axum::{routing::{get, post}, Json, Router, extract::Query};
use utoipa::{OpenApi, JsonSchema};
use serde::Deserialize;
// Query parameters need a separate struct.
// Deriving Deserialize handles axum extraction.
// Deriving JsonSchema handles utoipa documentation.
#[derive(Deserialize, JsonSchema)]
struct SearchParams {
q: String,
#[serde(default)]
limit: Option<u32>,
}
// Path parameters are extracted by axum and documented by utoipa.
// The param attribute tells utoipa where the value comes from.
#[derive(JsonSchema)]
struct UserId {
id: String,
}
// Request body shape for creating a resource.
// JsonSchema generates the schema for the request payload.
#[derive(JsonSchema, serde::Deserialize)]
struct CreateUser {
username: String,
email: String,
}
// Response shape for the created resource.
#[derive(JsonSchema, serde::Serialize)]
struct UserResponse {
id: String,
username: String,
email: String,
}
// Document a GET endpoint with query parameters.
// The params attribute links the SearchParams struct.
#[utoipa::path(
get,
path = "/search",
params(SearchParams),
responses(
(status = 200, description = "Search results", body = Vec<String>)
)
)]
async fn search(Query(params): Query<SearchParams>) -> Json<Vec<String>> {
Json(vec![format!("Results for {}", params.q)])
}
// Document a POST endpoint with path and body parameters.
// The request_body attribute defines the expected payload.
#[utoipa::path(
post,
path = "/users/{id}",
params(UserId),
request_body = CreateUser,
responses(
(status = 201, description = "User created", body = UserResponse),
(status = 400, description = "Invalid input", body = String)
)
)]
async fn create_user(
path: UserId,
Json(body): Json<CreateUser>
) -> Json<UserResponse> {
Json(UserResponse {
id: path.id,
username: body.username,
email: body.email,
})
}
#[derive(OpenApi)]
#[openapi(paths(search, create_user))]
struct ApiDoc;
The params attribute tells utoipa to pull the schema from SearchParams or UserId. The request_body attribute defines the payload shape. The responses attribute lists every possible HTTP status code and the associated body type. utoipa validates these types at compile time. If you reference a type that does not implement JsonSchema, the build fails.
Convention aside: keep your API routes in a dedicated module. Put all #[utoipa::path] handlers in api/routes.rs. Put all request/response types in api/models.rs. This separation makes it trivial to audit the public contract without scanning the entire codebase.
When the annotations fight back
utoipa is strict by design. It will not silently ignore missing schemas or mismatched types. The most common failure is forgetting to derive JsonSchema on a custom type used in a response or request body. The compiler rejects this with E0277 (trait bound not satisfied). The error message points to the #[utoipa::path] macro expansion and complains that your type does not implement utoipa::JsonSchema. Add the derive macro and the error disappears.
Another frequent issue is mixing String and &str in request bodies. utoipa expects owned types for JSON payloads because the deserialization process allocates new strings. If you try to document a handler that returns &'static str as a JSON body, the macro will complain about lifetime mismatches. Wrap the string in a Json<String> or a custom struct instead.
Generic types require explicit schema registration. If you use Result<T, E> or Vec<T> in your responses, utoipa handles them automatically. If you wrap them in a custom generic struct, you must annotate the generic parameters with #[into_params] or provide a concrete type alias. The compiler will not infer the schema for unbounded generics.
Pitfall warning: do not annotate internal helper functions. utoipa scans every function with a #[utoipa::path] attribute. If you accidentally annotate a private utility function, it will appear in your public API documentation. Keep the macro strictly on public-facing handlers.
Trust the macro errors. They point directly to the missing trait or mismatched type. Fix the derive, adjust the signature, and the spec updates automatically.
Picking your documentation strategy
Use utoipa when you are building a standard REST or GraphQL API and want compile-time safety for your documentation. Use utoipa when your team values automated contract generation over manual YAML maintenance. Use utoipa when you need Swagger UI or Redoc integration without runtime performance penalties.
Reach for manually written OpenAPI YAML when your API is extremely simple and you want full control over every schema field. Reach for manual YAML when you are documenting a third-party service you do not control. Reach for manual YAML when your organization requires strict versioned spec files stored in version control separate from the codebase.
Pick schemars when you only need JSON Schema generation for serialization/deserialization validation and do not care about HTTP routing metadata. Pick schemars when you are building configuration files or data interchange formats rather than web APIs. Pick schemars when you want zero dependency on web frameworks and pure type-to-schema mapping.
Treat your OpenAPI spec as a first-class artifact. Generate it in CI. Validate it against a schema linter. Fail the build if undocumented routes slip through.