> ## Documentation Index
> Fetch the complete documentation index at: https://rydantic.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a CRUD API

> Use one model for validated HTTP input, JSON responses and SQLite persistence.

Build a user service with create, list, fetch, update and delete endpoints. Run the checked-in example from a source checkout matching these docs; [setup instructions](/tutorials/overview) explain the prerequisites.

20 minutesAxum → validated model → SQLite → JSON

## 1. Follow one model through the service

The `User` model derives both `Model` and `SqlModel`. The first supplies parsing and output validation. The second supplies SQL metadata, migrations and CRUD operations.

`name` is trimmed, length-checked and unique. `age` must be between 18 and 120. SQLite supplies an ID when `id` is omitted. `User::migrate(&pool)` creates the table before the HTTP listener starts.

> **Complete runnable service**
>
> ```rust crates/rydantic/examples/sqlite_api.rs theme={"system"}
> //! Run with `cargo run -p rydantic --example sqlite_api --features axum,sqlite`.
> use rydantic::{
>     axum::{
>         framework::{
>             extract::{Path, State},
>             http::StatusCode,
>             routing::get,
>         },
>         Json, Router,
>     },
>     sqlx::{sqlite::SqlitePoolOptions, SqlitePool},
>     Model, SqlError, SqlModel,
> };
>
> #[derive(Debug, PartialEq, Model, SqlModel)]
> #[sql(table = "users")]
> pub struct User {
>     #[sql(primary_key, auto_increment)]
>     pub id: Option<i64>,
>     #[model(trim, min_length = 2, max_length = 100)]
>     #[sql(unique)]
>     pub name: String,
>     #[model(ge = 18, le = 120)]
>     #[sql(index)]
>     pub age: u8,
> }
> pub fn router(pool: SqlitePool) -> Router {
>     Router::new()
>         .route("/users", get(list).post(create))
>         .route("/users/{id}", get(find).put(update).delete(remove))
>         .with_state(pool)
> }
> async fn create(
>     State(pool): State<SqlitePool>,
>     Json(user): Json<User>,
> ) -> Result<(StatusCode, Json<User>), SqlError> {
>     Ok((StatusCode::CREATED, Json(user.insert(&pool).await?)))
> }
> async fn list(State(pool): State<SqlitePool>) -> Result<Json<Vec<User>>, SqlError> {
>     Ok(Json(User::select().limit(100).fetch_all(&pool).await?))
> }
> async fn find(State(pool): State<SqlitePool>, Path(id): Path<i64>) -> Result<Json<User>, SqlError> {
>     User::get(&pool, &id)
>         .await?
>         .map(Json)
>         .ok_or(SqlError::NotFound)
> }
> async fn update(
>     State(pool): State<SqlitePool>,
>     Path(id): Path<i64>,
>     Json(mut user): Json<User>,
> ) -> Result<Json<User>, SqlError> {
>     user.id = Some(id);
>     Ok(Json(user.update(&pool).await?))
> }
> async fn remove(
>     State(pool): State<SqlitePool>,
>     Path(id): Path<i64>,
> ) -> Result<StatusCode, SqlError> {
>     if User::delete(&pool, &id).await? {
>         Ok(StatusCode::NO_CONTENT)
>     } else {
>         Err(SqlError::NotFound)
>     }
> }
>
> #[tokio::main]
> async fn main() -> Result<(), Box<dyn std::error::Error>> {
>     let url = std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite::memory:".into());
>     let pool = SqlitePoolOptions::new()
>         .max_connections(1)
>         .connect(&url)
>         .await?;
>     User::migrate(&pool).await?;
>     if std::env::args().any(|arg| arg == "--smoke") {
>         use rydantic::axum::framework::{body::Body, http::Request};
>         use tower::ServiceExt;
>         let response = router(pool)
>             .oneshot(
>                 Request::builder()
>                     .method("POST")
>                     .uri("/users")
>                     .header("content-type", "application/json")
>                     .body(Body::from(r#"{"name":" Alice ","age":25}"#))?,
>             )
>             .await?;
>         assert_eq!(response.status(), StatusCode::CREATED);
>         println!("Validated HTTP -> SQLite -> JSON: OK");
>         return Ok(());
>     }
>     let address: std::net::SocketAddr = std::env::var("LISTEN_ADDR")
>         .unwrap_or_else(|_| "127.0.0.1:3000".into())
>         .parse()?;
>     if !address.ip().is_loopback() {
>         return Err(std::io::Error::new(
>             std::io::ErrorKind::InvalidInput,
>             "the unauthenticated demo requires a loopback LISTEN_ADDR",
>         )
>         .into());
>     }
>     let listener = tokio::net::TcpListener::bind(address).await?;
>     println!("Listening on http://{}", listener.local_addr()?);
>     rydantic::axum::framework::serve(listener, router(pool)).await?;
>     Ok(())
> }
> ```

The request extractor `Json<User>` validates before `create` executes. The handler calls `insert` and returns the stored row. That response is validated again by Rydantic's `Json` adapter.

## 2. Run the in-process check

```sh theme={"system"}
cargo run --locked -p rydantic --example sqlite_api --features axum,sqlite -- --smoke
```

Expected output:

```text theme={"system"}
Validated HTTP -> SQLite -> JSON: OK
```

The smoke mode exercises a real Axum request and an in-memory SQLite database, then exits. It opens no listening socket.

## 3. Start the server

```sh theme={"system"}
cargo run --locked -p rydantic --example sqlite_api --features axum,sqlite
```

Keep that terminal running. The default address is `http://127.0.0.1:3000`, backed by a fresh in-memory database. In another terminal, create a user:

```sh theme={"system"}
curl -i http://127.0.0.1:3000/users \
  -H 'Content-Type: application/json' \
  -d '{"name":" Ada ","age":36}'
```

Expect **201 Created** and a normalized row. On a fresh database it is:

```json theme={"system"}
{"age":36,"id":1,"name":"Ada"}
```

## 4. Reject invalid input

```sh theme={"system"}
curl -i http://127.0.0.1:3000/users \
  -H 'Content-Type: application/json' \
  -d '{"name":"A","age":17}'
```

Expect **422 Unprocessable Entity**, with errors at `name` and `age` in the `detail` array. No row is inserted. Check the list:

```sh theme={"system"}
curl http://127.0.0.1:3000/users
```

It still contains only Ada. Posting `Ada` again returns **409 Conflict** because the database enforces the unique name. Fetching a missing ID returns **404 Not Found**.

## 5. Update and delete a row

Use the ID returned by the create request. For ID `1`:

```sh theme={"system"}
curl -i -X PUT http://127.0.0.1:3000/users/1 \
  -H 'Content-Type: application/json' \
  -d '{"name":"Ada Lovelace","age":37}'
curl http://127.0.0.1:3000/users/1
curl -i -X DELETE http://127.0.0.1:3000/users/1
curl -i http://127.0.0.1:3000/users/1
```

The update returns the changed row, deletion returns **204 No Content**, and the final fetch returns **404**. Stop the demo server with `Ctrl-C`.

## 6. Keep data between restarts

Choose a new local database file:

```sh theme={"system"}
DATABASE_URL='sqlite://tutorial.db?mode=rwc' \
LISTEN_ADDR='127.0.0.1:3001' \
cargo run --locked -p rydantic --example sqlite_api --features axum,sqlite
```

The model is migrated at startup. Subsequent runs reuse the file. The demo accepts loopback listen addresses and has no authentication; use it for local learning.

To see a table evolve, run the migration example in its own in-memory database:

```sh theme={"system"}
cargo run --locked -p rydantic --example automatic_migrations --features sqlite
```

It creates an old `Account`, inserts a row, previews a rename and two added fields, then applies the plan. Assertions check that the old name survives, `enabled` is backfilled, and a second migration has no work.

> **Complete model-to-model migration**
>
> ```rust crates/rydantic/examples/automatic_migrations.rs theme={"system"}
> //! Upgrade an existing table by changing its Rust model definition.
> use rydantic::{sql::MigrationOptions, sqlx::sqlite::SqlitePoolOptions, Model, SqlModel};
> mod previous {
>     use super::*;
>     #[derive(Debug, Model, SqlModel)]
>     #[sql(table = "accounts")]
>     pub struct Account {
>         #[sql(primary_key, auto_increment)]
>         pub id: Option<i64>,
>         pub name: String,
>     }
> }
> #[derive(Debug, Model, SqlModel)]
> #[sql(table = "accounts")]
> struct Account {
>     #[sql(primary_key, auto_increment)]
>     id: Option<i64>,
>     #[sql(rename_from = "name", unique)]
>     display_name: String,
>     #[sql(default = true)]
>     enabled: bool,
>     note: Option<String>,
> }
> #[tokio::main]
> async fn main() -> Result<(), Box<dyn std::error::Error>> {
>     let pool = SqlitePoolOptions::new()
>         .max_connections(1)
>         .connect("sqlite::memory:")
>         .await?;
>     previous::Account::migrate(&pool).await?;
>     let old = previous::Account {
>         id: None,
>         name: "Alice".into(),
>     }
>     .insert(&pool)
>     .await?;
>     let plan = Account::migration_plan(&pool).await?;
>     for change in plan.changes() {
>         println!("{}", change.description);
>     }
>     let report = plan.apply(&pool, MigrationOptions::default()).await?;
>     let account = Account::get(&pool, &old.id.unwrap()).await?.unwrap();
>     assert_eq!(account.display_name, "Alice");
>     assert!(account.enabled);
>     assert!(account.note.is_none());
>     assert!(Account::migrate(&pool).await?.plan.is_empty());
>     println!(
>         "Applied revision {:?}: {}",
>         report.revision, report.checksum
>     );
>     Ok(())
> }
> ```

Inspect plans and validate upgrades on a copy before using automatic migrations with application data. The [migration guide](/integrations/migrations) documents supported changes and database-specific behavior.

> **[Next: build a typed agent](/tutorials/typed-agent)**
>
> Apply a Rust contract to an AI result.

> **[Use PostgreSQL or MySQL](/examples/services)**
>
> Run the same API with an independent SQL driver.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.