Bulk writes and performance
An import has thousands of users to save. Give Rent the collection instead of awaiting one insert per user. It can group the writes while keeping the import atomic.
Build the create builders, then send them together:
let creates = (1..=2_501_i64)
.map(|id| {
client
.create_user()
.id(id)
.name(format!("User {id}"))
})
.collect::<Vec<_>>();
let users = client
.create_user_many(creates)
.save()
.await?;Use .save() when the application needs the inserted models. Use .exec() when it only needs the affected-row count:
let result = client
.create_user_many(creates)
.exec()
.await?;
assert_eq!(result.inserted, 2_501);The count-only path avoids returning and decoding every inserted row.
For schemas without access, PostgreSQL and SQLite homogeneous rows use multi-row INSERT ... RETURNING requests and return typed entities.
MySQL and MariaDB batches with explicit IDs use a multi-row insert followed by a reload. Rent binds each ID once and
restores the caller's order in memory. Batches with database-generated IDs use the safe fallback because assuming a
contiguous ID range is unsafe under concurrency. Mixed shapes and SQL expressions also use the safe fallback.
With permissions enabled, Rent instead uses guarded batch inserts and verifies the final stored rows before committing. Both APIs check every candidate, stamp its creator, and retain tenant and application-policy restrictions. A denied row rolls back the whole batch. See the permission example for a multi-tenant sharing walkthrough.
For PostgreSQL ingestion jobs, generated clients also expose the server's native CSV copy protocol:
let result = client
.copy_user_csv(b"id,name\n8001,Copy one\n8002,Copy two\n")
.header(true)
.exec()
.await?;
assert_eq!(result.inserted, 2);The CSV columns follow the generated entity column order. COPY is deliberately unavailable when field codecs,
application policies, or mutation hooks must inspect individual rows, and on ordinary permission-enabled handles. Use create_user_many(...) in those cases.
You do not need to size chunks yourself. Rent plans them from the active dialect's bind-parameter, row-count, and payload limits. A 10,000-row call remains one atomic SDK operation even when it requires several SQL statements: Rent opens a transaction and rolls every chunk back if a later chunk fails.
For schemas without access, when no mutation hooks are registered, Rent sends the atomic insert directly and returns the decoded entities without
an extra transaction or serialization pass. When hooks are registered, Rent wraps the write and after-hooks in a
transaction so a rejected or rewritten result retains the documented rollback behavior.
The bulk operation is atomic. If one row violates a constraint, no row from that collection remains:
let rejected = client
.create_user_many([
client
.create_user()
.id(101)
.name("Would be rolled back"),
client
.create_user()
.id(1)
.name("Duplicate primary key"),
])
.save()
.await;
assert!(rejected.is_err());
assert!(
client
.user()
.id_eq(101)
.only_or_none()
.await?
.is_none()
);Complete example
Download this project, or copy the files below into an empty directory.
Replace RENT_REPOSITORY_URL in Cargo.toml with the URL supplied with your preview access. Use the matching Rent CLI release.
rent generate
cargo runThe project includes its database setup. Each lesson uses a small, self-contained schema. You can run it without applying the earlier lessons.
Cargo.toml
[package]
name = "rent-lesson-20-bulk-performance"
version = "0.1.0"
edition = "2024"
[workspace]
[[bin]]
name = "rent-lesson-20-bulk-performance"
path = "main.rs"
[dependencies]
rent = { git = "RENT_REPOSITORY_URL", tag = "v0.8.6", default-features = false, features = ["sqlite", "migrate", "extensions"] }
anyhow = "1.0.104"
tokio = { version = "1.53.1", features = ["full"] }rent.toml
schema = "schema.rsl"
generated = "generated"
migrations = "migrations"
[database]
dialect = "sqlite"
backends = ["sqlite"]schema.rsl
datasource db {
provider = "sqlite"
}
model User {
id BigInt @id
name String
@@map("users")
}main.rs
use anyhow::{Result, ensure};
use rent::driver::DatabasePool;
use rent::sql::Dialect;
#[allow(dead_code, unused_imports)]
#[rustfmt::skip]
#[path = "generated/mod.rs"]
mod generated;
async fn exercise() -> Result<()> {
let pool = DatabasePool::connect(Dialect::Sqlite, "sqlite::memory:").await?;
pool.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
.await?;
let client = generated::client::Client::new(pool);
let creates = (1..=2_501_i64)
.map(|id| client.create_user().id(id).name(format!("User {id}")))
.collect::<Vec<_>>();
let users = client.create_user_many(creates).save().await?;
ensure!(users.len() == 2_501);
ensure!(client.user().count().await? == 2_501);
let count_only = client
.create_user_many((5_000..=7_500_i64).map(|id| client.create_user().id(id).name(format!("Count only {id}"))))
.exec()
.await?;
ensure!(count_only.inserted == 2_501);
ensure!(client.user().count().await? == 5_002);
let mut invalid_batch = (3_000..4_000_i64)
.map(|id| client.create_user().id(id).name(format!("Rollback {id}")))
.collect::<Vec<_>>();
invalid_batch.push(client.create_user().id(1).name("Duplicate primary key"));
let rejected = client.create_user_many(invalid_batch).save().await;
ensure!(rejected.is_err());
ensure!(client.user().id_eq(3_000).only_or_none().await?.is_none());
ensure!(client.user().count().await? == 5_002);
let first = client.user().page_by_id(25, None).await?;
ensure!(first.items.len() == 25);
ensure!(first.has_more);
let second = client.user().page_by_id(25, first.next.as_ref()).await?;
ensure!(second.items.first().is_some_and(|user| user.id == 26));
let postgres_only = client.copy_user_csv(b"id,name\n8001,Copy one\n").header(true).exec().await;
ensure!(postgres_only.is_err());
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
exercise().await
}
#[cfg(test)]
mod tests {
#[tokio::test]
async fn example_runs() -> anyhow::Result<()> {
super::exercise().await
}
}