Getting Started with Diesel (the TypeORM of Rust)
18 min read
Diesel is Rust’s most established ORM and query builder. If you reach for TypeORM, Prisma, or Sequelize in Node, Diesel is the closest cultural fit: you describe your tables, derive structs that map to rows, and build queries through a strongly typed DSL — except the mapping is checked by the Rust compiler instead of at runtime.
Quick Overview
Section titled “Quick Overview”Diesel maps SQL tables to Rust types and gives you a typed query builder, much like TypeORM maps tables to entity classes. The defining difference is that Diesel is synchronous and compile-time-checked: a query that selects the wrong column or loads into the wrong type is a build error, not a runtime exception. For a TypeScript developer the surprises are that there is no async/await here (Diesel uses a blocking connection you run on a thread or a blocking pool) and that the table definitions are generated for you by a CLI rather than declared with decorators.
Note: This file covers Diesel’s foundations — the
table!schema, model structs, project setup, and the synchronous execution model. Building queries (filter/order/insert/update) lives in diesel-queries.md, and associations/joins live in diesel-relations.md. If you want async with hand-written SQL instead, start at sqlx-intro.md.
TypeScript/JavaScript Example
Section titled “TypeScript/JavaScript Example”Here is a typical TypeORM setup: an entity class with decorators, a data source, and a couple of queries.
// TypeScript with TypeORMimport "reflect-metadata";import { Entity, PrimaryGeneratedColumn, Column, DataSource,} from "typeorm";
@Entity("users")class User { @PrimaryGeneratedColumn() id!: number;
@Column() name!: string;
@Column({ unique: true }) email!: string;
@Column({ default: true }) active!: boolean;}
const dataSource = new DataSource({ type: "postgres", url: process.env.DATABASE_URL, entities: [User], synchronize: true, // auto-creates tables in dev});
async function main() { await dataSource.initialize(); const repo = dataSource.getRepository(User);
// Insert const user = repo.create({ name: "Ada Lovelace", email: "ada@example.com" }); await repo.save(user); // user.id is now populated
// Query const found = await repo.findOneBy({ id: user.id }); console.log(found); // User { id: 1, name: 'Ada Lovelace', ... }
const active = await repo.findBy({ active: true }); console.log(`${active.length} active users`);}
main();Key points:
- The entity class is the single source of truth; decorators describe the columns.
synchronize: truelets TypeORM create the schema from the entity (dev only).- Everything is
asyncand returns aPromise. - A mistake like
repo.findOneBy({ idx: 1 })(typo in the column) is caught only when the query runs — TypeORM’s typings help, but raw query builders andfindoptions leave plenty of runtime gaps.
Rust Equivalent
Section titled “Rust Equivalent”In Diesel the schema and the model are two separate things. A table! macro invocation (normally generated by the Diesel CLI into src/schema.rs) describes the table’s columns and SQL types. Your own struct then derives Queryable/Selectable/Insertable to map rows to and from that table.
// Rust with Diesel 2.3 + SQLiteuse diesel::prelude::*;use diesel::sqlite::SqliteConnection;
// Normally generated by `diesel print-schema` into src/schema.rs.// You do NOT hand-write or hand-edit this in a real project.diesel::table! { users (id) { id -> Integer, name -> Text, email -> Text, active -> Bool, }}
// A row you read OUT of the database.#[derive(Queryable, Selectable, Debug)]#[diesel(table_name = users)]#[diesel(check_for_backend(diesel::sqlite::Sqlite))]struct User { id: i32, name: String, email: String, active: bool,}
// A row you write INTO the database. No `id`: the database assigns it.#[derive(Insertable)]#[diesel(table_name = users)]struct NewUser<'a> { name: &'a str, email: &'a str, active: bool,}
fn main() -> Result<(), Box<dyn std::error::Error>> { // In-memory SQLite so this example is fully self-contained. let mut conn = SqliteConnection::establish(":memory:")?;
// A migration normally creates the table; we do it inline here. diesel::sql_query( "CREATE TABLE users (\ id INTEGER PRIMARY KEY AUTOINCREMENT, \ name TEXT NOT NULL, \ email TEXT NOT NULL, \ active BOOLEAN NOT NULL DEFAULT 1)", ) .execute(&mut conn)?;
// Insert and get the full row back (id included). let new_user = NewUser { name: "Ada Lovelace", email: "ada@example.com", active: true, }; let inserted: User = diesel::insert_into(users::table) .values(&new_user) .returning(User::as_returning()) .get_result(&mut conn)?; println!("Inserted: {inserted:?}");
// Load every row. let all: Vec<User> = users::table .select(User::as_select()) .load(&mut conn)?; println!("All users: {all:?}");
Ok(())}This compiles and runs with the following dependencies. SQLite needs no server, so it is the easiest backend for trying Diesel locally:
[dependencies]diesel = { version = "2.3", features = ["sqlite", "returning_clauses_for_sqlite_3_35"] }# Bundles SQLite so you do not need a system library installed:libsqlite3-sys = { version = "0.37", features = ["bundled"] }Real program output:
Inserted: User { id: 1, name: "Ada Lovelace", email: "ada@example.com", active: true }All users: [User { id: 1, name: "Ada Lovelace", email: "ada@example.com", active: true }]Note:
diesel::table!anddiesel::table! { ... }are the same macro. Generatedschema.rsfiles typicallyuse diesel::prelude::*;and write it as a baretable!. This guide writesdiesel::table!so each snippet is copy-paste complete.
Detailed Explanation
Section titled “Detailed Explanation”table! describes the SQL table; it is generated, not authored
Section titled “table! describes the SQL table; it is generated, not authored”The table! macro is the heart of Diesel. It does not create a table in the database — it produces Rust types that describe a table that already exists. Each line like name -> Text introduces a column object (users::name) carrying its SQL type (diesel::sql_types::Text). The whole invocation also produces users::table (the table itself) and a users::dsl module for terse queries.
In TypeORM the entity class is both the schema description and the runtime model. In Diesel these are deliberately split:
schema.rs(thetable!output) is the description of what the database actually looks like.- Your structs are the Rust-side view of rows, and you can have several per table.
You keep schema.rs in sync with the database by running diesel print-schema (or letting diesel migration run regenerate it). You do not edit it by hand, just as you would not hand-edit a Prisma-generated client.
Model structs and their derives
Section titled “Model structs and their derives”The #[derive(...)] list on a struct is where the mapping happens. The common derives:
| Derive | Purpose | TypeORM analogy |
|---|---|---|
Queryable | Build the struct from a result row (read side) | The shape returned by repo.find... |
Selectable | Generate a SELECT list matching the struct fields | Choosing which columns to fetch |
Insertable | Turn the struct into an INSERT (write side) | repo.create(...) / repo.save(...) |
AsChangeset | Turn the struct into an UPDATE SET ... (covered in diesel-queries.md) | repo.update(...) |
#[diesel(table_name = users)] ties the struct to the table! you defined. #[diesel(check_for_backend(diesel::sqlite::Sqlite))] asks Diesel to verify, at compile time, that every field’s Rust type can actually be read from that column’s SQL type on that backend. This is the line that turns a column/type mismatch into a build error.
User::as_returning() and User::as_select() come from Selectable. They produce a select list (id, name, email, active) that is guaranteed to match the struct’s fields and order, so you never have to keep a hand-written column list in sync with the struct.
Read model vs. write model
Section titled “Read model vs. write model”Notice there are two structs: User (read) and NewUser (write). The write model omits id because the database generates it. This pattern is idiomatic Diesel and maps cleanly to TypeORM’s distinction between a saved entity and the partial object you pass to repo.create(). NewUser<'a> borrows its strings (&'a str) so inserting does not require cloning your input data.
The synchronous model: there is no await
Section titled “The synchronous model: there is no await”This is the biggest mental shift for a Node developer. establish, execute, load, and get_result are blocking calls. They return Result<T, diesel::result::Error> directly, not a Future. There is no async, no .await, and no runtime required for the example above.
In an async web server (for example an Axum handler — see Section 16), a blocking Diesel call would stall the async runtime’s worker thread. The standard fix is to run Diesel on a dedicated blocking pool (tokio::task::spawn_blocking with an r2d2 connection pool, covered in connection-pooling.md). If you would rather stay fully async, that is exactly what SQLx and the diesel-async crate are for.
Tip: Diesel returning a plain
Resultrather than aFutureis the same “lazy vs. eager, runtime-or-not” theme from Section 11: Async: synchronous code needs no executor, so the entry point is an ordinaryfn main, not an async one.
Key Differences
Section titled “Key Differences”| Aspect | TypeORM / Prisma (TypeScript) | Diesel (Rust) |
|---|---|---|
| Schema source of truth | Entity class / schema.prisma | Database migrations → generated schema.rs |
| Model definition | One class with decorators | table! (generated) + your own derived structs |
| Column/type errors | Mostly runtime; partial compile help | Compile-time error via check_for_backend |
| Concurrency model | async/await, returns Promise | Synchronous, returns Result (no runtime) |
| Auto-create schema | synchronize: true | Explicit migrations (diesel migration run) |
| Generics at runtime | Erased; reflection via reflect-metadata | Monomorphized; no reflection, all static |
| Query mistakes (typos) | Often surface as runtime SQL errors | Will not compile if the column/type is wrong |
Why this design
Section titled “Why this design”Diesel’s whole premise is to push as many SQL mistakes as possible into the compiler. Because users::email is a distinct Rust type carrying Text, comparing it to an integer or loading it into an i32 simply has no valid trait implementation, so the program does not build. TypeORM cannot do this: TypeScript types are erased at runtime (see Section 09), so the ORM relies on decorators and runtime checks. Diesel trades the convenience of synchronize: true and a single entity file for the guarantee that a green build means your column mappings are sound.
Common Pitfalls
Section titled “Common Pitfalls”Pitfall 1: Mismatching a field’s Rust type and the column’s SQL type
Section titled “Pitfall 1: Mismatching a field’s Rust type and the column’s SQL type”This is the mistake check_for_backend exists to catch. Declaring id: String when the column is Integer:
// does not compile (error[E0277]): id is String but the column is Integer#[derive(Queryable, Selectable)]#[diesel(table_name = users)]#[diesel(check_for_backend(diesel::sqlite::Sqlite))]struct User { id: String, // wrong: the `id` column is Integer name: String, email: String, active: bool,}The real compiler error (trimmed for brevity — Diesel also prints a help: list of the SQL types String can be read from):
error[E0277]: the trait bound `String: FromSqlRow<diesel::sql_types::Integer, Sqlite>` is not satisfied --> src/main.rs:17:9 |13 | #[derive(Queryable, Selectable)] | ---------- in this derive macro expansion...17 | id: String, // wrong: the `id` column is Integer | ^^^^^^ the trait `FromSql<diesel::sql_types::Integer, Sqlite>` is not implemented for `String` | = note: double check your type mappings via the documentation of `diesel::sql_types::Integer` ... = note: required for `String` to implement `diesel::Queryable<diesel::sql_types::Integer, Sqlite>` = note: required for `String` to implement `FromSqlRow<diesel::sql_types::Integer, Sqlite>`In TypeORM the equivalent typo compiles and blows up at runtime. Read the trait-bound message as “this Rust type cannot be read from this column type” and check your table! vs. struct mapping.
Pitfall 2: Forgetting the prelude or the RunQueryDsl methods
Section titled “Pitfall 2: Forgetting the prelude or the RunQueryDsl methods”.execute(), .load(), .get_result(), and .first() are trait methods. If you forget use diesel::prelude::*; you get a “method not found” error even though the code looks right. The prelude re-exports RunQueryDsl, QueryDsl, SelectableHelper (for as_select/as_returning), and Connection. Import it once at the top of every module that talks to the database.
Pitfall 3: Expecting async / .await
Section titled “Pitfall 3: Expecting async / .await”// does not compile: establish() returns a Result, not a Future.// let conn = SqliteConnection::establish(":memory:").await?;Diesel is synchronous. There is nothing to .await. If you write .await on a Diesel call you will get an error that the value is not a Future. For async code, run Diesel via spawn_blocking (see connection-pooling.md) or use SQLx / diesel-async.
Pitfall 4: Treating first() like findOneBy returning null
Section titled “Pitfall 4: Treating first() like findOneBy returning null”repo.findOneBy() in TypeORM resolves to null when nothing matches. Diesel’s .first()/.get_result() instead return Err(diesel::result::Error::NotFound) when there is no row. To get TypeScript-like “maybe a row” behavior, call .optional(), which converts NotFound into Ok(None):
use diesel::prelude::*;// (inside a function with `conn: &mut SqliteConnection` and the `users` table in scope)// Returns Option<User>: Some(user) or None, never an Err for "not found".// let maybe_user: Option<User> = users::table// .find(42)// .select(User::as_select())// .first(conn)// .optional()?;Warning: Do not ignore the
Resultfrom a Diesel call. Unlike a rejected Promise that you might forget toawait, a RustResultyou never inspect triggers amust_usewarning — but only?or amatchactually handles the error. See Section 08: Error Handling.
Best Practices
Section titled “Best Practices”- Let the CLI own
schema.rs. Install the CLI withcargo install diesel_cli --no-default-features --features sqlite(swapsqliteforpostgres/mysqlas needed) and regenerate the schema from migrations rather than editing it. Treat it like Prisma’s generated client. - Always add
#[diesel(check_for_backend(...))]toQueryable/Selectablestructs. It is the line that turns silent mapping bugs into compile errors; skipping it gives back the runtime-surprise behavior you came to Rust to avoid. - Keep separate read and write models. A
QueryableUserand anInsertableNewUserkeepid/created_atout of inserts and make your intent explicit. - Prefer
Selectable+as_select()over relying on column order. It frees you from manually matching theSELECTlist to the struct and survives schema reordering. - Pick the SQLite backend with the bundled feature for examples and tests.
libsqlite3-syswithfeatures = ["bundled"]means no system SQLite install, and:memory:databases make tests hermetic. - Run blocking calls off the async runtime. In an Axum/Tokio app, wrap Diesel work in
spawn_blockingand use anr2d2pool (enable ther2d2feature). Details in connection-pooling.md.
Real-World Example
Section titled “Real-World Example”A small user-store module that resembles a repository layer you might build behind a web handler. It creates the schema, inserts a couple of users, fetches one by primary key, and counts the active ones. It is fully self-contained on SQLite and prints real values.
// Cargo.toml:// [dependencies]// diesel = { version = "2.3", features = ["sqlite", "returning_clauses_for_sqlite_3_35"] }// libsqlite3-sys = { version = "0.37", features = ["bundled"] }
use diesel::prelude::*;use diesel::sqlite::SqliteConnection;
diesel::table! { users (id) { id -> Integer, name -> Text, email -> Text, active -> Bool, }}
#[derive(Queryable, Selectable, Debug)]#[diesel(table_name = users)]#[diesel(check_for_backend(diesel::sqlite::Sqlite))]struct User { id: i32, name: String, email: String, active: bool,}
#[derive(Insertable)]#[diesel(table_name = users)]struct NewUser<'a> { name: &'a str, email: &'a str, active: bool,}
/// Create the schema. A real app uses a migration instead (see migrations.md).fn create_schema(conn: &mut SqliteConnection) -> QueryResult<usize> { diesel::sql_query( "CREATE TABLE users (\ id INTEGER PRIMARY KEY AUTOINCREMENT, \ name TEXT NOT NULL, \ email TEXT NOT NULL, \ active BOOLEAN NOT NULL DEFAULT 1)", ) .execute(conn)}
fn insert_user(conn: &mut SqliteConnection, new: &NewUser) -> QueryResult<usize> { diesel::insert_into(users::table).values(new).execute(conn)}
/// TypeORM's `findOneBy({ id })`, but "not found" is a real None.fn find_user(conn: &mut SqliteConnection, user_id: i32) -> QueryResult<Option<User>> { users::table .find(user_id) .select(User::as_select()) .first(conn) .optional()}
fn count_active(conn: &mut SqliteConnection) -> QueryResult<i64> { users::table .filter(users::active.eq(true)) .count() .get_result(conn)}
fn main() -> Result<(), Box<dyn std::error::Error>> { let mut conn = SqliteConnection::establish(":memory:")?; create_schema(&mut conn)?;
insert_user(&mut conn, &NewUser { name: "Ada", email: "ada@x.com", active: true })?; insert_user(&mut conn, &NewUser { name: "Alan", email: "alan@x.com", active: false })?;
match find_user(&mut conn, 1)? { Some(user) => println!("Found: {} <{}>", user.name, user.email), None => println!("No user with that id"), }
println!("Missing user: {:?}", find_user(&mut conn, 999)?); println!("Active users: {}", count_active(&mut conn)?);
Ok(())}Real program output:
Found: Ada <ada@x.com>Missing user: NoneActive users: 1Each function returns QueryResult<T> (an alias for Result<T, diesel::result::Error>), so callers propagate failures with ? — the same composable error story as the rest of your Rust code, and unlike TypeORM where the database error type is opaque and easy to swallow.
Further Reading
Section titled “Further Reading”- Diesel Getting Started guide — the official walkthrough for the CLI, migrations, and
schema.rs. - Diesel API docs (docs.rs) —
table!,Queryable,Selectable,Insertable, and the connection types. diesel_clion crates.io — install options per backend.- Sibling topics in this section:
- Diesel query builder —
filter/select/order/insert/update/deleteand the DSL. - Diesel relationships —
belongs_to/has_manyassociations and eager loading. - Migrations —
diesel migration generate/run, up/down, running at startup. - Connection pooling —
r2d2, sizing, and running Diesel off an async runtime. - ORM comparison — Diesel vs. SQLx vs. SeaORM, and when to choose each.
- SQLx intro — the async, compile-time-checked SQL alternative.
- Diesel query builder —
- Background from earlier sections:
- Section 09: Generics & Traits — derives are trait implementations; this is why mapping is checked statically.
- Section 08: Error Handling —
Result,?, and.optional(). - Section 11: Async — why Diesel’s synchronous model needs no runtime.
- Next section: Section 18: CLI Tools —
diesel_cliis itself a Rust CLI, and the same patterns power your own tools.
Exercises
Section titled “Exercises”Exercise 1: Add a column and keep the mapping honest
Section titled “Exercise 1: Add a column and keep the mapping honest”Difficulty: Beginner
Objective: Practice keeping a table! definition and a model struct in agreement.
Instructions: Starting from the users table in this file, add a bio column of SQL type Text to the table! and a matching field to the User struct. Then deliberately give the new field the wrong Rust type (for example bool) and observe the compile error. Fix it so the program builds.
Solution
use diesel::prelude::*;use diesel::sqlite::SqliteConnection;
diesel::table! { users (id) { id -> Integer, name -> Text, email -> Text, active -> Bool, bio -> Text, // added }}
#[derive(Queryable, Selectable, Debug)]#[diesel(table_name = users)]#[diesel(check_for_backend(diesel::sqlite::Sqlite))]struct User { id: i32, name: String, email: String, active: bool, bio: String, // matches `Text`. (Using `bool` here fails to compile, like Pitfall 1.)}
fn main() -> Result<(), Box<dyn std::error::Error>> { let mut conn = SqliteConnection::establish(":memory:")?; diesel::sql_query( "CREATE TABLE users (\ id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, \ email TEXT NOT NULL, active BOOLEAN NOT NULL DEFAULT 1, \ bio TEXT NOT NULL DEFAULT '')", ) .execute(&mut conn)?;
let all: Vec<User> = users::table.select(User::as_select()).load(&mut conn)?; println!("{} users", all.len()); // prints: 0 users Ok(())}The compiler rejects bio: bool with the same FromSql<Text, Sqlite> is not implemented for bool family of errors shown in Pitfall 1; switching it to String makes it build.
Exercise 2: A separate write model
Section titled “Exercise 2: A separate write model”Difficulty: Intermediate
Objective: Build an Insertable write model and round-trip a row.
Instructions: Define a NewUser struct (no id) deriving Insertable, insert it, then load all users and print them. Use RETURNING so the insert hands you back the full User with its generated id.
Solution
use diesel::prelude::*;use diesel::sqlite::SqliteConnection;
diesel::table! { users (id) { id -> Integer, name -> Text, email -> Text, active -> Bool, }}
#[derive(Queryable, Selectable, Debug)]#[diesel(table_name = users)]#[diesel(check_for_backend(diesel::sqlite::Sqlite))]struct User { id: i32, name: String, email: String, active: bool,}
#[derive(Insertable)]#[diesel(table_name = users)]struct NewUser<'a> { name: &'a str, email: &'a str, active: bool,}
fn main() -> Result<(), Box<dyn std::error::Error>> { let mut conn = SqliteConnection::establish(":memory:")?; diesel::sql_query( "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, \ name TEXT NOT NULL, email TEXT NOT NULL, active BOOLEAN NOT NULL DEFAULT 1)", ) .execute(&mut conn)?;
let inserted: User = diesel::insert_into(users::table) .values(&NewUser { name: "Grace", email: "grace@x.com", active: true }) .returning(User::as_returning()) .get_result(&mut conn)?; println!("Inserted id={}", inserted.id); // Inserted id=1
let all: Vec<User> = users::table.select(User::as_select()).load(&mut conn)?; println!("{all:?}"); Ok(())}The insert requires the returning_clauses_for_sqlite_3_35 feature on the diesel dependency, which is shown in the Cargo.toml above.
Exercise 3: TypeORM’s findOneBy with honest absence
Section titled “Exercise 3: TypeORM’s findOneBy with honest absence”Difficulty: Intermediate
Objective: Reproduce repo.findOneBy({ id }) returning a nullable result, using Diesel’s Result/Option model.
Instructions: Write fn find_user(conn: &mut SqliteConnection, id: i32) -> QueryResult<Option<User>> that returns Ok(Some(user)) when the row exists and Ok(None) when it does not — never an Err for the “missing” case. Call it for both an existing and a non-existing id.
Solution
use diesel::prelude::*;use diesel::sqlite::SqliteConnection;
diesel::table! { users (id) { id -> Integer, name -> Text, email -> Text, active -> Bool, }}
#[derive(Queryable, Selectable, Debug)]#[diesel(table_name = users)]#[diesel(check_for_backend(diesel::sqlite::Sqlite))]struct User { id: i32, name: String, email: String, active: bool,}
fn find_user(conn: &mut SqliteConnection, id: i32) -> QueryResult<Option<User>> { users::table .find(id) .select(User::as_select()) .first(conn) .optional() // turns Err(NotFound) into Ok(None)}
fn main() -> Result<(), Box<dyn std::error::Error>> { let mut conn = SqliteConnection::establish(":memory:")?; diesel::sql_query( "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, \ name TEXT NOT NULL, email TEXT NOT NULL, active BOOLEAN NOT NULL DEFAULT 1)", ) .execute(&mut conn)?; diesel::sql_query("INSERT INTO users (name, email) VALUES ('Edsger', 'e@x.com')") .execute(&mut conn)?;
println!("{:?}", find_user(&mut conn, 1)?.map(|u| u.name)); // Some("Edsger") println!("{:?}", find_user(&mut conn, 999)?.map(|u| u.name)); // None Ok(())}The key is .optional(): without it, find(999) would return Err(Error::NotFound) and propagate out of the function, which is rarely what a lookup-by-id helper should do.