#[derive(Table)] turns a plain struct into the row type, a table handle, an insert struct and a &'static TableDef. The struct is the schema; migrations diff that metadata against a database.

use besu::prelude::*;

#[derive(Table)]
#[besu(table = "users")]
pub struct User {
    #[besu(primary_key, auto_increment)] pub id: i64,
    #[besu(unique)] pub email: String,
    pub name: String,
}

table (alias name)

The SQL table name; without it the struct name becomes snake_case. The handle identifier comes from the table name, so #[besu(table = "user_profiles")] generates user_profiles.

schema

PostgreSQL's schema, MySQL's database or SQLite's attached database; it qualifies every name, is part of the table's identity, and renders CREATE TABLE "app"."users" (...). Besu never runs CREATE SCHEMA, so leave it off for the default schema: PostgreSQL reports a table in current_schema() as unqualified.

#[besu(table = "users", schema = "app")]

column (alias name)

The SQL column name; without it the field name is used verbatim.

#[besu(column = "email_address")]
pub email: String,      // "email_address" TEXT NOT NULL

primary_key (alias primary)

One field is an inline PRIMARY KEY; two or more become a table-level constraint in declaration order. A primary key column may not be nullable.

#[besu(primary_key)] pub user_id: i64,
#[besu(primary_key)] pub team_id: i64,
CREATE TABLE "memberships" ("user_id" BIGINT NOT NULL, "team_id" BIGINT NOT NULL,
    PRIMARY KEY ("user_id", "team_id"))

auto_increment (alias autoincrement)

The database generates the value, which also removes the column from users.insert(..).

#[besu(primary_key, auto_increment)]
pub id: i64,
DialectRendered
SQLite"id" INTEGER PRIMARY KEY AUTOINCREMENT
PostgreSQL"id" BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY
MySQL`id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY

SQLite ignores it unless the column is also the primary key; MySQL requires it to be a key.

unique

A UNIQUE constraint on the column, rendered inline as "email" TEXT NOT NULL UNIQUE.

#[besu(unique)]
pub email: String,

MySQL cannot index TEXT or BLOB without a prefix, so it writes UNIQUE KEY `email` (`email`(255)): uniqueness over the first 255 characters.

index

A separate, non-unique, single-column index named {table}_{column}_idx, rendering CREATE INDEX "users_created_at_idx" ON "users" ("created_at").

#[besu(index)]
pub created_at: chrono::DateTime<chrono::Utc>,

Composite and partial indexes have no attribute; see the constraints page.

Defaults: default, default_now, default_sql

A default makes the column optional in an insert; it does not change the Rust type, so the model still holds bool, not Option<bool>.

#[besu(default = false)] pub published: bool,       // DEFAULT FALSE
#[besu(default = 0)] pub views: i32,                // DEFAULT 0
#[besu(default_now)] pub created_at: chrono::DateTime<chrono::Utc>,
#[besu(default_sql = "gen_random_uuid()")] pub public_id: uuid::Uuid,

default_now renders DEFAULT CURRENT_TIMESTAMP and default_sql renders its string verbatim, unchecked. default takes a string, integer, float or boolean literal. MySQL wraps a default on TEXT, BLOB or JSON in parentheses.

sql_type

Names a SqlType implementation instead of inferring one from the field's type.

#[besu(sql_type = "besu::Decimal<10, 2>")]
pub amount: String,     // NUMERIC(10,2)
#[besu(sql_type = "besu::VarChar<64>")]
pub label: String,      // VARCHAR(64)

The field's Rust type must be the SqlType's Rust type — String for Decimal, Char and VarChar. A custom marker implements SqlType, and its Rust type needs Decode per driver.

references, on_delete, on_update

A single-column foreign key named {table}_{column}_fkey; references must be "table.column" or "schema.table.column", and a missing dot is a compile error. The clause is CONSTRAINT "posts_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "users" ("id"), with the ON DELETE and ON UPDATE actions appended.

#[besu(references = "users.id", on_delete = "cascade", on_update = "restrict")]
pub author_id: i64,

Accepted actions are cascade, set_null/setnull, set_default/setdefault, restrict and no_action/noaction; anything else falls back to no clause, so a typo is silent.

renamed_from

Declares that the column had another name, so push renames it instead of dropping and adding it. It produces no DDL in CREATE TABLE; a push emits ALTER TABLE "users" RENAME COLUMN "full_name" TO "name".

#[besu(column = "name", renamed_from = "full_name")]
pub name: String,

skip

Not a column at all: absent from the SELECT list, the insert struct and the TableDef. Decoding fills it with Default::default(), so the field must implement Default.

#[besu(skip)]
pub cached_score: f64,

What the derive generates

ItemWhat it is
UserThe row type, with Decode<Dr> implemented for every driver
UserTable, usersThe handle's type, implementing Table, and its const; users.def() is the &'static TableDef
UserInsertThe insert struct, implementing Insertable

There is no separate inferred-select type: the struct is what a SELECT returns. A column reference carries its name, SQL type and nullability, so users.id.name() is "id" and users.id.type_def() is TypeDef::Int8.

UserInsert stores every column as a Set<T>, keeping "not provided" distinct from "explicitly NULL", and From<User> for UserInsert converts a whole model, leaving the auto-increment unset.

Nullability

Nullability is a type parameter on Column (Column<T, S, N = NonNull>), not a wrapper around the SQL type, so Option never nests: the derive strips one Option and rejects the inner one, making Option<Option<T>> a compile error. A primary key or auto-increment column may not be nullable.

What is checked

  • An unmapped field type, a generic or non-struct table, a nullable primary key, a bad references string, an unknown key and two same-named tables are compile errors.
  • Drift is a runtime failure, caught by push unless the change is destructive.

Gotchas

  • The mapping reads only the last path segment, so a type alias can map to the wrong SQL type. time::Date is not chrono::NaiveDate.
  • DateTime is matched by name, not by its full type. chrono::DateTime<Local> is declared TimestampTz, but no driver implements Decode for it. Use DateTime<Utc>.
  • Generated names are fixed: {table}_{column}_idx and {table}_{column}_fkey.
  • #[besu(skip)] removes the field from reading and writing. For a column the database fills in, use default or auto_increment instead.