How to Map Database Rows to Rust Structs

Use serde or sqlx to automatically map database rows to Rust structs for type-safe data handling.

When raw rows meet typed structs

You run a query against your database. The database hands back a row: a flat list of values with column names attached. Your Rust code needs a User struct with typed fields, validation, and methods. Bridging that gap used to mean writing tedious match statements or fighting with raw byte arrays. Modern Rust handles it with derive macros that turn database rows into structs automatically. You just need to know which tool fits your stack and how to handle the edge cases where the database schema and your code disagree.

The mapping machine

Database rows are unstructured at the application level. They are just arrays of values paired with string labels. Rust structs are strictly typed containers. Mapping them means matching each column label to a struct field and converting the database type into a Rust type. Think of it like a postal sorting facility. The database delivers a batch of unsorted packages. The sorting machine reads the address label on each package, checks it against a routing table, and drops it into the correct bin. In Rust, the sorting machine is a trait implementation. The routing table is the column names and types. The bins are your struct fields.

The two main sorting machines are sqlx for SQL databases and serde for JSON or other serialized formats. Both use derive macros to generate the mapping code for you. You do not write the sorting logic. You declare the destination bins and let the compiler generate the conveyor belt.

Trust the macro. It writes the repetitive boilerplate so you can focus on the domain logic.

Minimal setup

use sqlx::FromRow;

/// Represents a user record pulled directly from the database.
#[derive(FromRow)]
struct User {
    // Maps to the 'id' column. i32 matches the database integer type.
    id: i32,
    // Maps to the 'name' column. String handles variable-length text.
    name: String,
    // Maps to the 'email' column.
    email: String,
}

// The query selects exactly the columns the struct expects.
// sqlx checks the types at compile time, not at runtime.
let user: User = sqlx::query_as!(
    User,
    "SELECT id, name, email FROM users WHERE id = ?",
    1
)
.fetch_one(&pool)
.await
.expect("User not found");

The #[derive(FromRow)] macro expands into a trait implementation that tells sqlx how to read a row. When you call query_as!, the macro parses the SQL string. It extracts the column names and their expected types from the database schema. It then verifies that your User struct has matching fields. If the database says id is an integer and your struct says id: i32, the compiler approves the mapping. If you change the database schema later, the next compile run will fail before the code ever ships.

This compile-time verification separates sqlx from older runtime-only approaches. Runtime mappers guess the types when the query executes. They panic if a column is missing or a type does not match. Compile-time mappers catch the mismatch while you are still writing the code. The tradeoff is that sqlx needs access to the database schema during compilation, either through an offline file or a live connection.

Keep your schema in sync with your code. Run cargo sqlx prepare before every build to catch drift early.

How the compiler verifies the route

Under the hood, FromRow relies on the Decode trait. Each database driver implements Decode for the Rust types it supports. When the macro expands, it generates a match statement that iterates over the row's columns. For each column, it looks up the field name, checks the database type, and calls the appropriate Decode implementation. If the column is NULL, it checks whether the struct field is wrapped in Option. If it is, the mapper returns None. If it is not, the mapper returns an error.

The macro also generates a compile-time assertion. It compares the list of struct fields against the list of selected columns. If a struct field has no matching column, the compiler emits a trait bound error. The error message points to the missing FromRow implementation. The fix is usually a typo in the column name or a missing SELECT clause.

Convention aside: always run cargo sqlx prepare with the SQLX_OFFLINE=true environment variable in CI. This forces the compiler to verify queries against a cached schema file instead of hitting a live database. It speeds up builds and prevents accidental schema mutations during testing.

Let the compiler do the verification. Your job is to align the types.

Real schemas and real friction

Real databases rarely match Rust naming conventions perfectly. Database columns use snake_case. Rust fields often use snake_case too, but sometimes you need to rename fields, handle nullable columns, or map computed values.

use sqlx::FromRow;

/// User profile with optional metadata and renamed database columns.
#[derive(FromRow)]
struct UserProfile {
    // Database column is user_id, but we want a cleaner field name.
    #[sqlx(rename = "user_id")]
    id: i32,

    // Database column is nullable. Rust requires Option to represent missing data.
    bio: Option<String>,

    // Computed column from the database. Must match the exact alias.
    #[sqlx(rename = "is_active")]
    active: bool,
}

// The query selects exactly the columns the struct expects.
// Aliases must match the rename attributes exactly.
let profile: UserProfile = sqlx::query_as!(
    UserProfile,
    r#"
        SELECT 
            user_id, 
            bio, 
            (created_at > '2023-01-01') AS is_active 
        FROM users 
        WHERE user_id = ?
    "#,
    42
)
.fetch_one(&pool)
.await?;

The Option<T> wrapper is mandatory for nullable columns. If the database column allows NULL and your struct field is a plain String, the mapping will fail. The database returns a null value. Rust has no null type. Option bridges that gap by representing either Some(value) or None. The derive macro generates code that checks for null before unwrapping.

Convention aside: always use #[sqlx(rename = "...")] when the database column name differs from the Rust field. Do not rely on runtime string matching. Explicit renaming makes the contract visible to anyone reading the struct definition. It also prevents silent bugs when a developer renames a column in the database but forgets to update the Rust code.

Treat the Option wrapper as a contract. If the column can be null, the field must be optional.

When the mapping breaks

Mismatches happen. The compiler will stop you, but the error messages can feel dense if you do not know what to look for.

If you forget to mark a nullable column as Option<T>, sqlx rejects the code with a trait bound error. The compiler expects a type that implements Decode for that database driver, but it finds a non-optional type instead. You will see E0277 (trait bound not satisfied) pointing to the FromRow derive. Add Option and the error disappears.

If your struct field name does not match any column in the SELECT clause, you get a trait bound error. The compiler cannot find an implementation of FromRow because the routing table has no entry for that field. Check your column aliases. Rename the field or add the #[sqlx(rename)] attribute.

Type width mismatches are another common trap. PostgreSQL stores integers as i32 or i64 depending on the column definition. If your database uses BIGINT and your struct uses i32, the compiler flags it with a mismatched types error. You will see E0308 (mismatched types) complaining about the Decode implementation. Align the Rust type with the database type. Use i64 for BIGINT, f64 for DOUBLE PRECISION, and chrono::DateTime for timestamps.

Do not ignore the compiler here. The type system is doing the heavy lifting. Fix the struct, not the query.

Pick your mapper

Use sqlx with #[derive(FromRow)] when you work with SQL databases and want compile-time safety. The macro validates your schema against your structs before the binary runs. Use serde with #[derive(Deserialize)] when your data arrives as JSON, YAML, or other serialized formats. The mapping logic is identical, but the input stream is text, not a database driver. Use manual FromRow implementations when you need custom parsing logic that derive macros cannot express, like combining two columns into one field or applying runtime validation. Use an ORM like Sea-ORM when you prefer a higher-level abstraction that handles migrations, associations, and query building for you.

Convention aside: keep your unsafe blocks out of this layer. Database mapping is purely safe Rust. If you find yourself writing unsafe to parse a row, you are fighting the driver instead of using it. Step back and check the Decode trait implementations.

Match the tool to the data source. Let the macro handle the repetition.

Where to go next