Skip to content
Concepts

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 storeStart with
Text, flags, counts, or measurementsStrings, booleans, signed/unsigned integers, or floats
One of a known set of statesAn enum
An enum variant with extra informationAn enum that carries data
A structured value or collectionA typed JSON value or list, below
A date or timeDate and time types
An exact decimal valueDecimal
A portable identifierUUIDs

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 typeGenerated Rust typeJSON 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:

MeaningRust typePostgreSQL override
Absolute instantDateTime<Utc>timestamp with time zone (default)
Calendar dateNaiveDatedate
Time of day, not a durationNaiveTimetime(6) without time zone
Local date and time, without a timezoneNaiveDateTimetimestamp(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.