Skip to content

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.


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.


Here is a typical TypeORM setup: an entity class with decorators, a data source, and a couple of queries.

// TypeScript with TypeORM
import "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: true lets TypeORM create the schema from the entity (dev only).
  • Everything is async and returns a Promise.
  • 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 and find options leave plenty of runtime gaps.

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 + SQLite
use 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:

Cargo.toml
[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! and diesel::table! { ... } are the same macro. Generated schema.rs files typically use diesel::prelude::*; and write it as a bare table!. This guide writes diesel::table! so each snippet is copy-paste complete.


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 (the table! 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.

The #[derive(...)] list on a struct is where the mapping happens. The common derives:

DerivePurposeTypeORM analogy
QueryableBuild the struct from a result row (read side)The shape returned by repo.find...
SelectableGenerate a SELECT list matching the struct fieldsChoosing which columns to fetch
InsertableTurn the struct into an INSERT (write side)repo.create(...) / repo.save(...)
AsChangesetTurn 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.

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.

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 Result rather than a Future is the same “lazy vs. eager, runtime-or-not” theme from Section 11: Async: synchronous code needs no executor, so the entry point is an ordinary fn main, not an async one.


AspectTypeORM / Prisma (TypeScript)Diesel (Rust)
Schema source of truthEntity class / schema.prismaDatabase migrations → generated schema.rs
Model definitionOne class with decoratorstable! (generated) + your own derived structs
Column/type errorsMostly runtime; partial compile helpCompile-time error via check_for_backend
Concurrency modelasync/await, returns PromiseSynchronous, returns Result (no runtime)
Auto-create schemasynchronize: trueExplicit migrations (diesel migration run)
Generics at runtimeErased; reflection via reflect-metadataMonomorphized; no reflection, all static
Query mistakes (typos)Often surface as runtime SQL errorsWill not compile if the column/type is wrong

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.


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.

// 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 Result from a Diesel call. Unlike a rejected Promise that you might forget to await, a Rust Result you never inspect triggers a must_use warning — but only ? or a match actually handles the error. See Section 08: Error Handling.


  • Let the CLI own schema.rs. Install the CLI with cargo install diesel_cli --no-default-features --features sqlite (swap sqlite for postgres/mysql as needed) and regenerate the schema from migrations rather than editing it. Treat it like Prisma’s generated client.
  • Always add #[diesel(check_for_backend(...))] to Queryable/Selectable structs. 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 Queryable User and an Insertable NewUser keep id/created_at out of inserts and make your intent explicit.
  • Prefer Selectable + as_select() over relying on column order. It frees you from manually matching the SELECT list to the struct and survives schema reordering.
  • Pick the SQLite backend with the bundled feature for examples and tests. libsqlite3-sys with features = ["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_blocking and use an r2d2 pool (enable the r2d2 feature). Details in connection-pooling.md.

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: None
Active users: 1

Each 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.



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.

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.