Types and storage
A post has a title, a publication state, perhaps some tags and a date. Start by choosing a type for each value. Rent turns those choices into Rust fields and database storage:
enum PublicationState {
DRAFT
PUBLISHED
}
model Post {
id Uuid @id @default(uuid(7))
title String
state PublicationState @default(DRAFT)
tags String[] @default([])
published_at DateTime?
}String becomes a Rust string, PublicationState becomes a Rust enum, String[]
becomes a list, and ? makes the date optional. Most models need only a few of
these ordinary choices.
Choose the shape of a value
| You want to store | Start with |
|---|---|
| Text, flags, counts, or measurements | Strings, booleans, signed/unsigned integers, or floats |
| One of a known set of states | An enum |
| An enum variant with extra information | An enum that carries data |
| A structured value or collection | A typed JSON value or list, below |
| A date or time | Date and time types |
| An exact decimal value | Decimal |
| A portable identifier | UUIDs |
You can also store bytes, use your own encoded Rust types, or transform values with field codecs. The encryption tutorial shows AES-256-GCM protection for stored values. External blobs cover lazy reads, reference checks, and dual writes for object storage. PostgreSQL domains offer native schema types with a portable fallback, and views can provide generated read models.
The rest of this guide explains the choices in detail. Read the sections for the values your model actually uses. You do not need to configure them all.
Enum names and stored values
An enum variant's schema name can differ from its stored database value:
enum PostStatus {
DRAFT
PUBLISHED
}
model Post {
id BigInt @id
status PostStatus @default(DRAFT)
}Use the declared, unquoted variant in @default, even when it has a mapping. Here the default stored value is
draft. The same rule applies to optional enum fields and JSON-backed enum-list defaults such as
@default([DRAFT, PUBLISHED]). Empty list defaults are valid. Unknown variants, quoted database strings,
and defaults with the wrong shape are rejected at schema validation with a source location.
Every variant needs a distinct, nonempty stored value. The same names and mappings apply when you use that enum inside a reusable composite type.
Rent generates a shared Rust type in generated::enums. Schema variants become Rust-style names, while
database values and JSON use the declared mappings:
use generated::enums::PostStatus;
let post = client
.create_post()
.id(1)
.status(PostStatus::Draft)
.save()
.await?;
let published = client
.post()
.status_in([PostStatus::Published])
.all()
.await?;PostStatus::Draft.as_str() returns "draft", and the SchemaEnum trait lists every stored value in
declaration order as PostStatus::VALUES, for validating input or documenting it. Parse external input with
"draft".parse::<PostStatus>()?. An undeclared stored value returns UnknownEnumValue. Arbitrary strings
are not accepted by generated enum setters or filters. Database reads and JSON deserialization reject
unknown values instead of silently substituting a variant. Deploy readers that understand a new variant
before writing it during a rolling deployment.
Optional fields use Option<PostStatus> and lists use Vec<PostStatus>, including inside composite values.
Filters accept owned or borrowed enum collections. Defaults still come from the database when a field is
omitted. Explicit @rent.rustType overrides retain the application's chosen type and conversion contracts.
Generated enums do not by themselves change an existing column's SQL storage type.
Enum variants that carry data
When one variant of an enum needs data the others do not, such as a Swiss tournament's round count, declare the data on that variant instead of adding a nullable column beside the enum:
/// How a tournament pairs its players.
enum TournamentFormat {
SINGLE_ELIMINATION
ROUND_ROBIN
SWISS { rounds UInt }
}
model Tournament {
id BigInt @id
format TournamentFormat
}The generated TournamentFormat is an ordinary Rust enum, Swiss { rounds: u64 } included, so a Swiss tournament
without a round count cannot be written down, let alone stored:
let tournament = client
.create_tournament()
.id(1)
.format(TournamentFormat::Swiss { rounds: 6 })
.save()
.await?;
let swiss = client
.tournament()
.format_is(TournamentFormatVariant::Swiss)
.all()
.await?;The column is JSON-compatible on every database. A plain variant is stored as its label ("round_robin"), and a
variant with data as an object keyed by its label ({"swiss": {"rounds": 6}}). Serde serializes it the same way, so
the value can cross an API unchanged. format_is and format_in match by variant whether or not it carries data.
Giving a variant of an existing enum some data turns its text column into JSON. The planned migration keeps every
stored label as a JSON string, which is exactly how a variant without data is stored, so existing rows read back as
the same variants. SQLite stores both as text and converts nothing, so a SQLite database holding rows needs
UPDATE tournaments SET format = json_quote(format) before it reads them.
JSON and list defaults
Typed JSON columns
Declare a reusable value object with an RSL type when Rent should generate its Rust struct. A model field of
that type stores the whole object as JSON. Its setters accept the generated type. Member @map annotations
control JSON keys, literal member defaults apply during decoding when a member is missing, and replacing the
object is a whole-value write rather than a merge.
On PostgreSQL, @@fulltext([body.text]) indexes a String member directly. A SQL generated column can expose a
derived value through typed filters while the object remains the source of the data. See
search and filter a value object for the combined
schema and generated-client workflow.
For an application-owned type instead of a generated value object, use @rent.rustType:
A Json column can hold one of your own types: @rent.rustType names it, and the generated field is that type
instead of serde_json::Value. Anything that implements serde's Serialize and Deserialize fits, including an
enum whose variants carry data, which no plain column type can hold:
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Rule {
Percentage { value: u8, sticky: bool },
UserList { user_ids: Vec<String> },
Everyone,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Rules {
pub rules: Vec<Rule>,
pub default_enabled: bool,
}model FlagSetting {
id BigInt @id @rent.manual
rules Json @rent.rustType("flags::Rules")
fallback Json? @rent.rustType("flags::Rule")
}let rules = Rules {
rules: vec![Rule::Percentage { value: 25, sticky: true }],
default_enabled: false,
};
let flag = client
.create_flag_setting()
.id(1)
.rules(rules)
.save()
.await?;
let first = &flag.rules.rules[0];
assert!(matches!(first, Rule::Percentage { value: 25, .. }));Rent encodes the value on write and decodes it on read, so no conversion code sits between your types and the table. A stored document that no longer decodes into the type is an error when read, not a silently different value. The path must name a type the generated client can reach: when the client is generated into its own crate, put the types in that crate or in one it depends on.
Typed JSON lists
Scalar lists use JSON-compatible columns, with typed Rust values at the client boundary:
| RSL type | Generated Rust type | JSON representation |
|---|---|---|
Uuid[] / UUID[] | Vec<Uuid> | Canonical UUID strings |
DateTime[] | Vec<DateTime<Utc>> | UTC timestamp strings |
Decimal[] | Vec<Decimal> | Decimal strings preserving precision |
Bytes[] / Blob[] | Vec<Vec<u8>> | Arrays of byte arrays |
Json[] | Vec<Value> | Arrays of arbitrary JSON values |
Rent re-exports these types through rent::uuid, rent::chrono, rent::rust_decimal, and rent::serde_json.
Primitive lists similarly use Vec<String>, Vec<bool>, or the declared numeric type. Empty arrays are valid.
Null elements require a JSON element type. These lists are not PostgreSQL native SQL arrays. Use relationships
for collections of related records. A create or update sets the whole collection. An update can also append
elements atomically with push_<field>.
Filter on membership with has. It matches rows whose list holds the element, on every database:
let pending = client
.request()
.approvers_has("maya")
.all()
.await?;
let same = client.request().filter(Request::approvers.has("maya")).all().await?;has_every matches lists holding all of the given elements, has_some lists holding at least one, and is_empty
lists holding none. An empty set is held by every list and shares nothing with any:
let urgent_and_billing = client
.request()
.labels_has_every(["urgent", "billing"])
.all()
.await?;
let unassigned = client.request().approvers_is_empty().all().await?;These filters are available for lists of strings, UUIDs, booleans, and integers, whose stored JSON values compare exactly.
Validation checks built-in list defaults against the generated Rust serialization contract. Invalid UUIDs, timestamps without offsets, incompatible element shapes, and out-of-range numbers produce a source-located diagnostic. UUID and timestamp defaults are canonicalized. Decimal defaults should be quoted to avoid floating-point rounding. JSON-list strings remain strings even if they contain JSON text. Custom Rust type overrides use the application's serializer and require application-level validation.
Timestamp lists preserve JSON string precision instead of inheriting the SQL timestamp column's microsecond
limit. They do not gain native timestamp indexing or date operations merely by declaring DateTime[].
The rich-values tutorial demonstrates all five collections.
Declaring defaults
List defaults use array literals. For a Json field, put a JSON document inside a quoted string:
model Post {
id BigInt @id
tags String[] @default([])
weights Float[] @default([1.25, 1000])
settings Json @default("{\"commentsEnabled\":true}")
label Json @default("\"draft\"")
previous Json @default("null")
}The settings default is an object, label is a JSON string, and previous is JSON null, not SQL NULL.
Omit these fields when creating a record to use their database defaults. Changing a default through a migration
affects later inserts. It does not replace values already stored in existing rows. These defaults work on
PostgreSQL, MySQL, MariaDB, and SQLite. List elements must be literal values. Functions inside a list are rejected.
Automatic values
Choose whether Rent or the database supplies a missing value:
model Post {
id Uuid @id @default(uuid(7))
body String
created_at DateTime @default(now())
edited_at DateTime @updatedAt
}uuid() and uuid(4) generate random UUID v4 values. uuid(7) generates time-ordered UUID v7 values.
Both work with Uuid and String fields, including keys. Rent evaluates them when a create executes,
not while its builder is assembled. Bulk and nested creates evaluate missing defaults separately for each
record. Supplying a value explicitly keeps it. Clearing a nullable field keeps SQL NULL.
@updatedAt uses the application's UTC clock. Rent supplies it on creation and refreshes it when an update
supplies scalar fields, even if their values equal the stored values. Empty unversioned updates and changes
confined to a join table do not touch the parent row.
A versioned compare-and-swap update increments the version, so it also refreshes @updatedAt. Explicit
timestamps take precedence, including explicit nullable clears. With local timestamp storage, Rent writes
the current UTC wall-clock value without a timezone. Use timestamp storage, not a calendar-date or time-only
column, for this attribute.
@updatedAt is client behavior, not a database trigger: raw SQL and writes from another application must
set it themselves if they need the same convention.
By contrast, literal defaults, now(), autoincrement(), and dbgenerated("SQL") are database defaults.
They apply to inserts performed outside Rent too. Combining @default(now()) with @updatedAt uses the
database clock for creation and the application clock for updates. Keep application and database clocks
synchronized. Unsupported default functions or arguments produce a schema-location diagnostic.
Default values pass through mutation hooks and policies like explicit values. Transactions roll back their
database writes, but do not undo clock reads, generated UUIDs, or other application-side effects. Keep custom
Rust-schema default_function and update_default_function callbacks synchronous, side-effect-free, and
non-panicking. They take no arguments and return the field's Rust value type. Update callbacks run once per
nonempty update operation, not once per matched database row.
Try the rich-values tutorial for a runnable UUID and timestamp example.
Dates and times
RSL DateTime generates DateTime<Utc>. Parse external input once. The SDK keeps the typed value through
setters, filters, projections, cursors, and mutation hooks:
use rent::chrono::{DateTime, Utc};
let happened_at = DateTime::parse_from_rfc3339("2026-09-12T18:04:56+05:30")?
.with_timezone(&Utc);
let values = client
.advanced_value()
.happened_at_ge(happened_at)
.all()
.await?;Use Utc::now() for the current instant, or @default(now()) to let the database supply it. Literal defaults
are validated against the field's calendar semantics. PostgreSQL uses native timestamp with time zone.
MySQL and MariaDB use DATETIME(6) containing UTC. SQLite stores UTC text with fixed fractional precision,
so equal instants compare equally and fractions sort chronologically. Existing noncanonical text must be
normalized explicitly when adopting that storage contract. Changing a Rust type does not rewrite stored rows.
Different concepts have different Rust types:
| Meaning | Rust type | PostgreSQL override |
|---|---|---|
| Absolute instant | DateTime<Utc> | timestamp with time zone (default) |
| Calendar date | NaiveDate | date |
| Time of day, not a duration | NaiveTime | time(6) without time zone |
| Local date and time, without a timezone | NaiveDateTime | timestamp(6) without time zone |
model Appointment {
id BigInt @id
booked_at DateTime @default(now())
day DateTime @rent.type(postgres: "date", mysql: "date")
starts_at DateTime @rent.type(postgres: "time(6) without time zone", mysql: "time(6)")
local_at DateTime @rent.type(postgres: "timestamp(6) without time zone", mysql: "datetime(6)")
}Rust schemas can declare these Chrono types directly, imported from rent::chrono. Nullable fields become
Option<T>. Do not assign a timezone to a local wall-clock value by appending Z. Resolve its timezone in
your application first. A time-with-timezone-only column is not a substitute for a dated instant.
Server bindings retain microseconds. Sub-microsecond fractions are truncated consistently. SQLite retains nanoseconds. Returned records contain the stored precision. A column explicitly declared with fewer fractional digits may round further. Typed values support years 1 through 9999 on PostgreSQL and SQLite, and 1000 through 9999 on MySQL and MariaDB. Leap-second inputs and unsupported ranges are rejected before executing the statement.
The lower-level Argument::time(...) accepts RFC 3339 strings with Z or an explicit offset. Its validation
errors explain the format without exposing the input. Use native Chrono arguments for typed calendar semantics
and canonical SQLite storage. A rejected argument does not abort an existing database transaction.
Decimal values
A scalar Decimal uses rent::rust_decimal::Decimal in generated setters, predicates,
projections, and aggregate results. Use decimal strings rather than floating-point inputs
when exact decimal digits matter:
model Price {
id BigInt @id
amount Decimal @default("0")
}PostgreSQL uses numeric. MySQL and MariaDB use decimal(65,28) by default. SQLite
uses numeric affinity, which can convert values to binary floating point and cannot
promise the same precision for arbitrary decimal fractions. Decimal[] values are
JSON decimal strings and have a different storage contract from scalar SQL numbers.
UUIDs
Declare a Uuid field to get rent::uuid::Uuid in your generated models, setters, and filters. PostgreSQL
uses its native uuid column and parameter type. MySQL, MariaDB, and SQLite use canonical UUID text.
use rent::uuid::Uuid;
let user = client
.user()
.id_eq(Uuid::parse_str("67e5501a-2f2b-4c77-92cc-001122334455")?)
.only()
.await?;UUIDs can be primary keys, relationship keys, parts of compound keys, and keyset cursor values. Generated
eager loaders retain UUID bind types when reading related records. Hooks receive MutationValue::Uuid
so a rewritten UUID remains a UUID parameter.
For an existing text-backed PostgreSQL column, declare that storage choice explicitly:
model User {
id Uuid @id @rent.type(postgres: "text")
}The Rust API still uses Uuid. Generated writes and predicates use text parameters for that column. Changing
the Rust API to use UUIDs is not a reason to silently rewrite an existing database column. Use a reviewed
migration when you intentionally change its storage type.
