Object-Relational Mapping (ORM)
djangors-orm provides Django-style querysets, model metadata, expression macros, and database integration for PostgreSQL (via sqlx).
Defining Models
Models are Rust structs decorated with #[derive(Model)] from djangors-macros.
#![allow(unused)]
fn main() {
use djangors_orm::Model;
use djangors_macros::Model;
use djangors_orm::ForeignKey;
use chrono::{DateTime, Utc};
#[derive(Model, Debug, Clone)]
#[djangors(app = "polls", table_name = "polls_question", ordering = ["-pub_date"])]
pub struct Question {
#[djangors(primary_key, auto)]
pub id: i64,
#[djangors(max_length = 200)] pub question_text: String,
pub pub_date: DateTime<Utc>,
}
#[derive(Model, Debug, Clone)]
#[djangors(app = "polls", table_name = "polls_choice")]
pub struct Choice {
#[djangors(primary_key, auto)]
pub id: i64,
#[djangors(foreign_key(on_delete = "cascade", related_name = "choices"))]
pub question: ForeignKey<Question>,
#[djangors(max_length = 200)]
pub choice_text: String,
#[djangors(default = 0)]
pub votes: i32,
}
}
Model Attributes (#[djangors(...)])
Struct-Level Attributes
app = "app_label"(required): Specifies the application label (e.g.,"polls").table_name = "custom_table"(optional): Database table name. Defaults to{app_label}_{snake_case_struct_name}.ordering = ["field", "-field"](optional): Default ordering for querysets when.order_by()is not called. Prefix-denotes descending.unique_together = [["field1", "field2"]](optional): Defines composite unique constraints.indexes = ["index_name", ("index_name", ["field1", "field2"])](optional): Defines custom or composite database indexes. Can be a simple index name or a tuple of(name, [fields]).manager = "ManagerStruct"(optional): Overrides the default model manager with a custom manager struct.
Field-Level Attributes
primary_key: Marks the single primary key field. Automatically impliesuniqueanddb_index. Every model must have exactly one primary key field.auto: Marks auto-incrementing / database-generated fields (such asBIGSERIAL). Ignored duringsave()insertions.max_length = N: Sets maximum string length. Valid only onStringfields (maps toFieldKind::Char).Stringwithoutmax_lengthmaps toFieldKind::Text.default = value: Sets default field value (Text,I64,F64, orBool).unique: Adds aUNIQUEconstraint to the column.db_index: Creates a database index on the column.verbose_name = "...",help_text = "...": Descriptive labels for admin/forms metadata.column = "col_name": Overrides the database column name if different from the struct field name.foreign_key(on_delete = "...", related_name = "..."): Configures foreign key behavior.on_deleteoptions:"cascade"(default),"protect","set_null","restrict","do_nothing".
Supported Field Types
- Integer:
i32(FieldKind::Integer),i64(FieldKind::BigInt) - Float:
f32,f64(FieldKind::Float) - String:
String(FieldKind::CharorFieldKind::Text) - Boolean:
bool(FieldKind::Boolean) - Datetime:
chrono::DateTime<chrono::Utc>(FieldKind::DateTime) - Foreign Key:
ForeignKey<TargetModel> - One-to-One Key:
OneToOne<TargetModel> - Many-to-Many Marker:
ManyToMany<TargetModel> - Nullable:
Option<T>for any of the above types (exceptManyToMany)
Custom Managers
You can define custom database query manager behavior by implementing the Manager trait and specifying the manager class via the struct-level manager attribute:
#![allow(unused)]
fn main() {
use djangors_orm::{Model, q};
use djangors_macros::Model;
pub struct ActiveManager;
impl djangors_orm::meta::Manager<ManagedModel> for ActiveManager {
fn objects() -> djangors_orm::queryset::QuerySet<ManagedModel> {
djangors_orm::queryset::QuerySet::<ManagedModel>::new()
.filter(q!(is_active = true))
.expect("valid filter")
}
}
#[derive(Model, Debug)]
#[djangors(
app = "polls",
table_name = "polls_managed",
manager = "ActiveManager"
)]
pub struct ManagedModel {
#[djangors(primary_key, auto)]
pub id: i64,
pub name: String,
pub is_active: bool,
}
}
By overriding the manager, calling ManagedModel::objects() will automatically retrieve a queryset pre-filtered or configured according to your manager definition.
Note
Types like
NaiveDate,NaiveTime,Duration,Uuid, andDecimalare defined inFieldKindfor schema metadata, but currently trigger a compile error onModelderive for save/update operations.
QuerySet Methods
Access querysets using Model::objects() (or QuerySet::<T>::new()).
Important
objects(),meta(), andfield_values()are methods on theModeltrait. Every file that calls them needsuse djangors_orm::Model;— the#[derive(Model)]attribute does not pre-import it. A bareModel::objects()failing with “method not found” is this missing import, not a broken model.
Filtering and Ordering
.filter(q!(field = value, ...)): Applies filter expressions combined withAND. Accepts a wholeQ-style tree, soORandNOTcompose (see Combining filters)..exclude(q!(...)): The negation of.filter()— Django’s.exclude()..filter_or_icontains(&["field1", "field2"], "term"): Generates case-insensitiveILIKE %term%queries across specified text fields combined withOR..filter_datetime_range(field, gte, lt): Filters datetime fields in the half-open interval[gte, lt)..order_by("field")/.order_by("-field"): Orders results. Prefix-meansDESC..limit(n)/.offset(n): Paginates results using standard SQLLIMITandOFFSET..debug_sql()/.debug_params(): TheSELECTthis queryset would run and the parameters it would bind, for debugging and tests — Django’sstr(queryset.query). Placeholders stay as$1,$2, … rather than being interpolated, so the output is never a runnable statement.
Lookup Suffixes
Append a lookup to a field name to change the comparison. Without one, = is used.
| Suffix | SQL | Notes |
|---|---|---|
(none) / __eq | = | |
__ne | <> | |
__lt __lte __gt __gte | < <= > >= | |
__contains / __icontains | LIKE / ILIKE %v% | |
__startswith / __endswith | LIKE 'v%' / LIKE '%v' | |
__iexact | ILIKE 'v' | Case-insensitive, no wildcards |
__in | IN (...) | Takes a Vec; an empty one compiles to FALSE, never invalid IN () |
__isnull | IS NULL / IS NOT NULL | = false inverts it; binds no parameter |
__regex / __iregex | ~ / ~* | POSIX regular expression |
An unrecognised suffix is treated as part of the field name, so a typo surfaces
as OrmError::FieldNotFound rather than silently becoming an equality test.
Execution Methods
.all(db).await: Executes select query and returnsVec<T>..get(db).await: Fetches a single object. ReturnsOk(T),Err(OrmError::NotFound)if 0 rows returned, orErr(OrmError::MultipleObjectsReturned)if >1 row..first(db).await: ReturnsResult<Option<T>, OrmError>(LIMIT 1)..exists(db).await: ReturnsResult<bool, OrmError>check (SELECT 1 ... LIMIT 1)..count(db).await: ReturnsResult<i64, OrmError>count (SELECT COUNT(*) ...)..aggregate(db, vec![AggExpr::...]).await: Evaluates aggregate functions:AggExpr::Count,AggExpr::Sum,AggExpr::Avg,AggExpr::Min,AggExpr::Max..update(db, set!(...)).await: Performs bulk updates. ReturnsResult<u64, OrmError>(rows affected count)..delete(db).await: Deletes all rows matching the queryset’s filters. ReturnsResult<u64, OrmError>(rows affected count). Warning: calling.delete()on an unfiltered queryset (e.g.Model::objects().delete(db)) will delete every row in the table.QuerySet::<T>::delete_by_pk(db, pk).await: Static helper deleting a row by its primary key.
Eager-loading relations: select_related and prefetch_related
select_related loads a forward foreign key in two queries total (batch the
related ids, then one IN fetch), instead of one query per row. It returns
Vec<(T, Option<R>)> — the row, plus the related model or None when the
foreign key is dangling.
#![allow(unused)]
fn main() {
use polls::models::{Question, Choice};
use djangors_orm::{Model, prefetch_related};
async fn run(db: &djangors_db::Database) -> Result<(), Box<dyn std::error::Error>> {
// One query for choices, one for their questions — not N+1.
let rows: Vec<(Choice, Option<Question>)> = Choice::objects()
.select_related::<Question, _>(db, "question")
.await?;
for (choice, question) in &rows {
let text = question.as_ref().map(|q| q.question_text.as_str()).unwrap_or("[missing]");
println!("{} -> {text}", choice.choice_text);
}
Ok(())
}
}
prefetch_related does the reverse direction: given a list of parent rows, it
batch-loads the children that FK to them in one query. It is a free function —
call it with the parents and the related_name (needs ForeignKey
to declare one).
#![allow(unused)]
fn main() {
use polls::models::{Question, Choice};
use djangors_orm::{Model, prefetch_related, q};
async fn run(db: &djangors_db::Database) -> Result<(), Box<dyn std::error::Error>> {
let questions = Question::objects().all(db).await?;
// One extra query fills in every question's `choices` (related_name).
let by_question: std::collections::HashMap<i64, Vec<Choice>> =
prefetch_related(db, &questions, "choices").await?;
for question in &questions {
println!("{0}: {1} choices", question.question_text, by_question.get(&question.id).map_or(0, Vec::len));
}
Ok(())
}
}
Use select_related for -to-one hops and prefetch_related for -to-many
reversed relations; both exist purely to avoid the N+1 query pattern.
Locking rows: select_for_update
.filter(...).select_for_update() prefixes the query with FOR UPDATE, so the
matched rows are locked until the transaction commits. Combined with
.nowait() or .skip_locked() it implements the “claim one of N jobs” pattern
without double-claiming.
#![allow(unused)]
fn main() {
use polls::models::{Question, Choice};
use djangors_orm::{Model, q};
async fn run(db: &djangors_db::Database, choice_id: i64) -> Result<(), Box<dyn std::error::Error>> {
db.transaction(|conn| {
Box::pin(async move {
let choice: Choice = Choice::objects()
.filter(q!(id = choice_id))? // note: no `.await` — still building the query
.select_for_update()
.get(conn)
.await?;
// `choice` is locked for this transaction; other transactions block or skip.
Result::<(), djangors_orm::OrmError>::Ok(())
})
}).await?;
Ok(())
}
}
Note
select_for_updatemust run inside a transaction — calling it against a bare poolconnreturnsOrmError::SelectForUpdateOutsideTransaction. Use the Postgresdb.transaction(|conn| ...)form (ortransaction_conn, which works on both backends).nowait,skip_locked, andlock_ofare Postgres-only; SQLite rejects them withUnsupportedOnDialect.
Expressions & Macros
q! Macro
Constructs lookup filter expressions:
use polls::models::{Question, Choice};
use djangors_orm::{q, Model};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let qs = Question::objects().filter(q!(question_text = "What is your name?"))?;
Ok(())
}
Every field inside q! accepts a lookup suffix typed on the ident itself —
q!(field__lt = v), q!(field__in = vec![..]), etc. — it is not a separate
feature. The full set lives in the lookup table above; here
are the ones you reach for most day-to-day:
use polls::models::{Question, Choice};
use djangors_orm::{q, Model};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let qs = Choice::objects()
// Range: votes > 5 AND votes <= 100
.filter(q!(votes__gt = 5i32))?
.filter(q!(votes__lte = 100i32))?
// Null check — the field must be nullable (Option<T> / Option<ForeignKey>):
.filter(q!(choice_text__isnull = false))?
// Set membership and negation:
.filter(q!(id__in = vec![1i64, 2, 3]))?
.filter(q!(votes__ne = 0i32))?
// Case-insensitive substring:
.filter(q!(choice_text__icontains = "rust"))?;
let _ = qs;
Ok(())
}
Combining filters: OR, AND, NOT
A q!(...) produces a value you can combine with | (OR), & (AND), and !
(NOT), which is how Django’s Q objects spell the same thing. Successive
.filter() calls are still ANDed together.
use polls::models::{Question, Choice};
use djangors_orm::{q, Model};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// WHERE (votes = 0 OR votes > 100) AND NOT (choice_text = 'spam')
let qs = Choice::objects()
.filter(q!(votes = 0i32) | q!(votes__gt = 100i32))?
.filter(!q!(choice_text = "spam"))?;
// `.exclude()` is the same as filtering on a negation.
let qs = Choice::objects().exclude(q!(votes = 0i32))?;
let _ = qs;
Ok(())
}
Comparing two columns: q_f!
Where q! compares a column to a bound value, q_f! compares two columns on
the same row — F() on the filter side. The lookup suffix rides on the
left-hand field, as it does in Django.
use polls::models::{Question, Choice};
use djangors_orm::{q_f, Model};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// WHERE "id" <> "votes" — neither side is a bind parameter.
let qs = Choice::objects().filter(q_f!(id__ne votes))?;
let _ = qs;
Ok(())
}
Correlated Subqueries: Exists, NotExists, OuterRef, q_outer!
Use Exists or NotExists with q_outer! and OuterRef to construct correlated subqueries:
use polls::models::{Question, Choice};
use djangors_orm::{q, q_outer, Exists, NotExists, OuterRef, Model};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Questions with at least one choice with votes > 0:
let popular_questions = Question::objects()
.filter(
Exists::<Choice>::new()
.filter(q_outer!(question = OuterRef("id")))?
.filter(q!(votes__gt = 0i32))?,
)?;
// Questions without choice with votes > 0:
let unpopular_questions = Question::objects()
.filter(
NotExists::<Choice>::new()
.filter(q_outer!(question = OuterRef("id")))?
.filter(q!(votes__gt = 0i32))?,
)?;
let _ = (popular_questions, unpopular_questions);
Ok(())
}
Note
An
ExistsorNotExistssubquery whose filters contain noOuterRefis an uncorrelated subquery. It evaluates once for the entire query and does not correlate rows between the subquery and outer query.
Grouping: annotate and values
.annotate() is Django’s .values(...).annotate(...): it groups by the given
fields and computes aggregates per group, returning GroupRows rather than
model instances.
#![allow(unused)]
fn main() {
use polls::models::{Question, Choice};
use djangors_orm::{Model, aggregate::AggExpr};
async fn run(db: &djangors_db::Database) -> Result<(), Box<dyn std::error::Error>> {
let rows = Choice::objects()
.annotate(db, &["question"], vec![("total", AggExpr::Sum { field: "votes" })])
.await?;
for row in &rows {
let question_id = row.key("question");
let total = row.get("total");
println!("{question_id:?} => {total:?}");
}
Ok(())
}
}
Ordering is dropped for grouped queries, because ordering by a column that is
not in the GROUP BY is not valid SQL; sort the returned rows in Rust instead.
.values() and .values_list() select a projection instead of whole models,
which is useful when a table has columns you do not want to pay to fetch:
#![allow(unused)]
fn main() {
use polls::models::{Question, Choice};
use djangors_orm::Model;
async fn run(db: &djangors_db::Database) -> Result<(), Box<dyn std::error::Error>> {
let rows = Question::objects().values(db, &["id", "question_text"]).await?;
let ids = Question::objects().values_list(db, "id").await?;
let _ = (rows, ids);
Ok(())
}
}
set! Macro & F Expressions
Constructs update assignment lists, supporting F expressions for in-database atomic updates:
#![allow(unused)]
fn main() {
use polls::models::{Question, Choice};
use djangors_orm::{q, Model};
async fn run_update(db: &djangors_db::Database, choice_id: i64) -> Result<(), Box<dyn std::error::Error>> {
use djangors_orm::{set, F};
// Increment votes by 1 atomically in SQL: UPDATE polls_choice SET votes = votes + 1 WHERE ...
Choice::objects()
.filter(q!(id = choice_id))?
.update(db, set!(votes = F("votes") + 1))
.await?;
Ok(())
}
}
Model Instance Persistence Methods
Models derived with #[derive(Model)] implement instance CRUD operations:
model.save(db).await: Inserts a new row into the database, ignoringautofields, and returns a fresh model instance with database-assigned primary key values.model.update(db).await: Updates all non-primary key fields of an existing row matchingmodel.id. ReturnsErr(OrmError::NotFound)if no row matched.model.delete(db).await: Deletes the row matchingmodel.id. ReturnsErr(OrmError::NotFound)if no row was deleted.
Important
saveis INSERT-only — it always creates a new row. Callingsave()on a row you already fetched or previously saved inserts a duplicate; useupdate()for anything that already exists. There is no automatic new-vs-persisted detection — track that yourself.
Constructing models with foreign keys (ForeignKey::new)
A ForeignKey<T> field is not magically constructed — build it with
ForeignKey::new(id) exactly like any other field value. You generally save the
parent first to obtain its id, then insert the child.
#![allow(unused)]
fn main() {
use polls::models::{Question, Choice};
use djangors_orm::ForeignKey;
async fn run(db: &djangors_db::Database) -> Result<(), Box<dyn std::error::Error>> {
let question = Question {
id: 0,
question_text: "What is your name?".to_string(),
pub_date: chrono::Utc::now(),
};
let question = question.save(db).await?; // INSERT; `question.id` now holds the DB-assigned pk.
let choice = Choice {
id: 0,
question: ForeignKey::<Question>::new(question.id), // the FK wrapper.
choice_text: "Alice".to_string(),
votes: 0,
};
let choice = choice.save(db).await?;
let _ = choice;
Ok(())
}
}
Writes that don’t need the returned row (bulk insert of many children, e.g.)
can skip save and use QuerySet::<T>::insert_raw(db, values) or
QuerySet::<T>::bulk_create(db, &items) instead; bulk_create inserts many
rows in one statement.
Relationships & Many-to-Many
djangors-orm supports Foreign Key, One-to-One, and Many-to-Many relationships.
Foreign Keys (ForeignKey<T>)
Foreign keys reference the primary key of another model:
#![allow(unused)]
fn main() {
#[djangors(foreign_key(on_delete = "cascade", related_name = "choices"))]
pub question: ForeignKey<Question>,
}
To access the related ID directly, read choice.question.id. To load the related model instance lazily, call .fetch(&db):
#![allow(unused)]
fn main() {
if let Some(question) = choice.question.fetch(db).await? {
println!("{}", question.question_text);
}
}
One-to-One (OneToOne<T>)
A OneToOne<T> relationship is declared using the OneToOne wrapper. While it behaves similarly to a ForeignKey, migrations will automatically emit a UNIQUE constraint on the underlying column to ensure uniqueness:
#![allow(unused)]
fn main() {
#[derive(Model, Debug, Clone)]
#[djangors(app = "polls", table_name = "polls_user_profile")]
pub struct UserProfile {
#[djangors(primary_key, auto)]
pub id: i64,
#[djangors(foreign_key(on_delete = "cascade", related_name = "profile"))]
pub user: OneToOne<User>,
}
}
Like foreign keys, you can resolve the relationship lazily via .fetch(db):
#![allow(unused)]
fn main() {
let profile = UserProfile::objects().get(db).await?;
if let Some(user) = profile.user.fetch(db).await? {
println!("User name: {}", user.name);
}
}
Many-to-Many (ManyToMany<T>)
A ManyToMany<T> relation uses the ManyToMany wrapper as a zero-sized marker field. This field does not correspond to any column in the model’s table. Instead, a dedicated through (junction) table named {source_table_name}_{field_name} is created automatically by migrations.
#![allow(unused)]
fn main() {
#[derive(Model, Debug, Clone)]
#[djangors(app = "polls", table_name = "polls_school")]
pub struct School {
#[djangors(primary_key, auto)]
pub id: i64,
pub name: String,
#[djangors(many_to_many(related_name = "schools"))]
pub students: ManyToMany<Student>,
}
}
You can interact with Many-to-Many relationships using the free helper functions m2m_related, m2m_add, and m2m_remove:
#![allow(unused)]
fn main() {
use djangors_orm::{m2m_add, m2m_related, m2m_remove};
// Add relationship entries
m2m_add::<School, Student, _>(db, school.id, "students", student.id).await?;
// Query related instances
let students = m2m_related::<School, Student, _>(db, school.id, "students").await?;
// Remove relationship entries
m2m_remove::<School, Student, _>(db, school.id, "students", student.id).await?;
}
Query Result Caching
djangors-orm provides an opt-in, in-process, process-local query cache to avoid hitting the database for duplicate queries.
Enabling and Querying the Cache
You can enable result caching on a QuerySet via .cache(duration) and fetch cached results via .cached_all(db):
#![allow(unused)]
fn main() {
use std::time::Duration;
// First call queries the database and caches the results
let questions = Question::objects()
.filter(q!(pub_date__year = 2026))?
.cache(Duration::from_secs(60))
.cached_all(db)
.await?;
// Subsequent calls with the exact same SQL and parameters
// will hit the process-local cache instead of the database.
let cached_questions = Question::objects()
.filter(q!(pub_date__year = 2026))?
.cache(Duration::from_secs(60))
.cached_all(db)
.await?;
}
Invalidation
Write operations (save(), update(), and delete()) automatically invalidate all cached entries associated with the affected model’s table.
Important
The query cache is process-local and resides entirely in-memory. In multi-process deployments, writes to one process will not invalidate the caches of other processes, which can lead to stale data being served. Use with caution in distributed environments.
Database Routing
For multi-database setups, djangors-orm provides metadata-level routing via ModelDatabaseMap. This allows you to map specific model struct names to distinct database instances:
#![allow(unused)]
fn main() {
use djangors_orm::ModelDatabaseMap;
// Construct routing map
let routing_map = ModelDatabaseMap::new(
default_db,
vec![
("User", auth_db),
("Session", session_db),
],
);
// Retrieve the database configured for a specific model
let db = routing_map.db_for::<User>();
}