How we made SeaORM synchronous
SeaORM began as Rust's first async‑first ORM. Now we've come full circle with a synchronous crate: perfect for building lightweight CLI programs with SQLite.
In this post, we'll share how we ported a complex library like SeaORM, the tricks we learnt along the way, and the steps we're taking to keep it maintainable for the long run.
Gist
We took an approach of translation: we wrote a script to convert async functions into synchronous ones. (It's more than a simple find‑and‑replace.)
The script would read the src directory and rewrite that into a new crate sea-orm-sync. This crate isn't a fork: it will be continually rebased on sea-orm, inheriting all new features and bug fixes.
sea-orm-sync supports the entire SeaORM's API surface, including recent features like Entity Loader, Nested ActiveModel and Entity-First workflow.
Async -> Sync
At a high level, async Rust can be seen as a more complex form of sync Rust. So converting an async program is possible by stripping out the runtime and removing all async / await usage.
However, you can't always go from sync to async. Async Rust tightens lifetime rules and introduces Send / Sync requirements for futures and async closures, so existing sync code may fail those constraints.
Now, let's go from all the necessary conversions, in order of complexity:
1. async / await
Removing all async and .await keywords will almost make it compile.
2. #[main] / #[test]
Simply remove tokio / async-std from Cargo and remove #[tokio::main]. Then replace #[tokio::test] with #[test].
3. async_trait
Simply remove #[async_trait] usage and it's Cargo dependency.
4. Send + Sync
Most Send and Sync in trait bounds can be removed.
5. BoxFuture
If you've used BoxFuture you can use the following shim:
#[cfg(not(feature = "sync"))]
type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
#[cfg(feature = "sync")]
type BoxFuture<'a, T> = T;
6. Box::pin(async move { .. })
Function signature: FnOnce() -> Pin<Box<dyn Future<Output = Result<T, E>> + Send + 'c>> can simply be FnOnce() -> Result<T, E>.
async fn transaction<F, T, E>(&self, _callback: F) -> Result<T, TransactionError<E>>
where
F: for<'c> FnOnce(
&'c DatabaseTransaction,
) -> Pin<Box<dyn Future<Output = Result<T, E>> + Send + 'c>>
+ Send,
T: Send,
E: std::fmt::Display + std::fmt::Debug + Send,
{}
fn transaction<F, T, E>(&self, _callback: F) -> Result<T, TransactionError<E>>
where
F: for<'c> FnOnce(&'c DatabaseTransaction) -> Result<T, E>,
E: std::fmt::Display + std::fmt::Debug,
{}
Usage: Async futures can be simply converted to {}.
db
.transaction::<_, _, DbErr>(|txn| {
Box::pin(async move {
let bakeries = Bakery::find()
.filter(bakery::Column::Name.contains("Bakery"))
.all(txn)
.await?;
Ok(())
})
})
.await?
db
.transaction::<_, _, DbErr>(|txn| {
let bakeries = Bakery::find()
.filter(bakery::Column::Name.contains("Bakery"))
.all(txn)?;
Ok(())
})?
7. Mutex
The semantic difference in lock() between a synchronous mutex (std::sync::Mutex) and an asynchronous mutex (tokio::sync::Mutex or async_std::sync::Mutex) is crucial.
std::sync::Mutex::lock()
fn lock(&self) -> LockResult<MutexGuard<T>>
-
Fallible: Returns a Result because the lock can be poisoned.
-
Poisoning happens if a thread panics while holding the lock.
tokio::sync::Mutex::lock().await
async fn lock(&self) -> MutexGuard<'_, T>
-
Infallible: always succeeds and returns a guard
-
In async world mutexes don't get poisoned. A panic inside a task would abort the task, but would not affect other tasks. This is actually a problem in async Rust as a task can fail silently
In practice, we did:
#[cfg(not(feature = "sync"))]
let conn = *self.conn.lock().await;
#[cfg(feature = "sync")]
let conn = *self.conn.lock().map_err(|_| DbErr::MutexPoisonError)?;
8. Stream
This is the biggest discrepency between sync and async Rust. Simply put, Stream is the async version of Iterator:
Iterator | Stream | |
|---|---|---|
| Definition | Synchronous iterator | Asynchronous iterator |
| Trait method | fn next(&mut self) -> Option<Item> | fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Item>> |
| Consumption | Call .next() repeatedly | Call .next().await repeatedly (via StreamExt) |
| Blocking vs yielding | Produces items immediately, blocks until ready | Produces items asynchronously, yields if not ready |
| Use cases | Iterating over collections | Reading database rows |
| Usage | for x in iter { ... } | while let Some(x) = stream.next().await { ... } |
Stream occurs in many places throughout the SeaORM API. The Stream trait is replaced by the Iterator trait.
Box<dyn Stream>
#[cfg(not(feature = "sync"))]
type PinBoxStream<'a> = Pin<Box<dyn Stream<Item = Result<QueryResult, DbErr>> + 'a + Send>>;
#[cfg(feature = "sync")]
type PinBoxStream<'a> = Box<dyn Iterator<Item = Result<QueryResult, DbErr>> + 'a>;
impl Stream bounds
async fn stream<'a: 'b, 'b, C>(self, db: &'a C)
-> Result<impl Stream<Item = Result<E::Model, DbErr>> + 'b + Send, DbErr>
where
C: ConnectionTrait + StreamTrait + Send,
{}
fn stream<'a: 'b, 'b, C>(self, db: &'a C)
-> Result<impl Iterator<Item = Result<E::Model, DbErr>> + 'b, DbErr>
where
C: ConnectionTrait + StreamTrait,
{}
impl Stream for
#[cfg(not(feature = "sync"))]
impl Stream for TransactionStream<'_> {
type Item = Result<QueryResult, DbErr>;
fn poll_next(
self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> Poll<Option<Self::Item>> {
Pin::new(self.stream).poll_next(cx)
}
}
#[cfg(feature = "sync")]
impl Iterator for TransactionStream<'_> {
type Item = Result<QueryResult, DbErr>;
fn next(&mut self) -> Option<Self::Item> {
self.stream.next()
}
}
TryStreamExt
There is no equivalent to TryStreamExt in Rust's standard library, luckily it's very easy to make a shim:
while let Some(item) = stream.try_next().await? {
let item: fruit::ActiveModel = item.into();
}
pub trait TryIterator<T, E> {
fn try_next(&mut self) -> Result<Option<T>, E>;
}
impl<I, T, E> TryIterator<T, E> for I
where
I: Iterator<Item = Result<T, E>>,
{
fn try_next(&mut self) -> Result<Option<T>, E> {
self.next().transpose() // Option<Result<T>> becomes Result<Option<T>>
}
}
9. File / Network I/O
This is very application specific. In SeaORM's case, the external I/O is handled by rusqlite and sqlx respectively. Their APIs differ significantly, that's why we have written sea-query-sqlx and sea-query-rusqlite to align them.
For HTTP requests, you can simply use the sync and async versions of Client in different contexts.
For file I/O, the API difference between sync and async Rust is very small.
Conclusion: SQLite + SeaORM Sync = ⚡
You can now use sea-orm-sync in CLI programs, and only bringing in small number of additional dependencies compared to having to bring in the async ecosystem.
In fact, the compilation time speaks for itself. The async version of quickstart took 30 seconds to compile, while the sync version only took 15 seconds!
Right now only rusqlite is supported, but SeaORM's entire API surface is available. It's a breeze to add SQLite query capabilities to CLI programs where async would be overkill.
let db = &sea_orm::Database::connect("sqlite::memory:")?;
// Setup the database: create tables
db.get_schema_registry("sea_orm_quickstart::*").sync(db)?;
info!("Create user Bob with a profile:");
let bob = user::ActiveModel::builder()
.set_name("Bob")
.set_email("bob@sea-ql.org")
.set_profile(profile::ActiveModel::builder().set_picture("Tennis"))
.insert(db)?;
info!("Query user with profile in a single query:");
let bob = user::Entity::load()
.filter_by_id(bob.id)
.with(profile::Entity)
.one(db)?
.expect("Not found");
assert_eq!(bob.name, "Bob");
assert_eq!(bob.profile.as_ref().unwrap().picture, "Tennis");
SeaORM 2.0 RC
SeaORM 2.0 is shaping up to be our most significant release yet - with a few breaking changes, plenty of enhancements, and a clear focus on developer experience.
SeaORM 2.0 has reached its release candidate phase. We'd love for you to try it out and help shape the final release by sharing your feedback.
🦀 Rustacean Sticker Pack
The Rustacean Sticker Pack is the perfect way to express your passion for Rust. Our stickers are made with a premium water-resistant vinyl with a unique matte finish.
Sticker Pack Contents:
- Logo of SeaQL projects: SeaQL, SeaORM, SeaQuery, Seaography
- Mascots: Ferris the Crab x 3, Terres the Hermit Crab
- The Rustacean wordmark
Support SeaQL and get a Sticker Pack!

