Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Database Enum Types

Overview

Embedded enums with string labels use the best available enum representation for the target database by default. On databases with native enum types, Toasty uses them. On databases without native enums, Toasty falls back to string columns with constraints where possible, or plain string columns as a last resort.

No annotation is needed to get this behavior — the simplest enum definition gets the best storage automatically:

#![allow(unused)]
fn main() {
#[derive(toasty::Embed)]
enum Status {
    Pending,
    Active,
    Done,
}
}

On PostgreSQL this creates a CREATE TYPE status AS ENUM type. On MySQL it uses an inline ENUM(...) column. On SQLite it uses a TEXT column with a CHECK constraint. On DynamoDB it stores a plain string.

Discriminant types

Toasty supports three discriminant storage strategies for embedded enums:

Enum definitionStorage strategy
String labels (default or explicit)Native enum representation per backend
#[column(type = varchar)] or #[column(type = text)]Plain string column, no DB-level enum enforcement
#[column(variant = N)] with integersINTEGER column

Default: native enum

When an enum uses string labels (either default identifiers or explicit #[column(variant = "label")]), Toasty uses native enum storage:

#![allow(unused)]
fn main() {
#[derive(toasty::Embed)]
enum Status {
    Pending,          // label: 'pending'
    Active,           // label: 'active'
    Done,             // label: 'done'
}
}

This is equivalent to writing #[column(type = enum)] explicitly.

Opting out: plain string column

Use #[column(type = varchar)] or #[column(type = text)] to store the discriminant as a plain string column with no database-level enum type or constraint:

#![allow(unused)]
fn main() {
#[derive(toasty::Embed)]
#[column(type = text)]
enum Status {
    Pending,
    Active,
    Done,
}
}

This stores discriminants in a TEXT column. The database accepts any string value; Toasty is responsible for writing correct values. Use this when you need to interoperate with external tools that write directly to the table, or when you want to avoid database-level enum machinery for any reason.

Integer discriminants

Integer discriminants remain unchanged from existing behavior:

#![allow(unused)]
fn main() {
#[derive(toasty::Embed)]
enum Status {
    #[column(variant = 1)]
    Pending,
    #[column(variant = 2)]
    Active,
    #[column(variant = 3)]
    Done,
}
}

This stores discriminants as an INTEGER column. Integer and string discriminants cannot be mixed in the same enum.

Variant labels

Toasty converts Rust variant identifiers to snake_case for database labels by default, following the same convention used for table and column names:

Rust variantDefault label
Pending'pending'
InProgress'in_progress'
AlmostDone'almost_done'

Use #[column(variant = "label")] on individual variants to override the default:

#![allow(unused)]
fn main() {
#[derive(toasty::Embed)]
enum Status {
    #[column(variant = "pending")]
    Pending,
    #[column(variant = "active")]
    Active,
    #[column(variant = "done")]
    Done,
}
}

Explicit labels and defaults can coexist:

#![allow(unused)]
fn main() {
#[derive(toasty::Embed)]
enum Status {
    #[column(variant = "in_progress")]
    InProgress,      // stored as 'in_progress' (explicit)
    Done,            // stored as 'done' (default snake_case)
}
}

Database Support

The default native enum strategy adapts to each backend’s capabilities:

BackendRepresentationValidation
PostgreSQLCREATE TYPE ... AS ENUM (named type)Database rejects invalid values
MySQLInline ENUM('a', 'b', 'c') column typeDatabase rejects invalid values
SQLiteTEXT column + CHECK constraintDatabase rejects invalid values
DynamoDBString attributeNo database-level validation (Toasty validates at the application level)

PostgreSQL

Toasty creates a standalone named type with CREATE TYPE ... AS ENUM and references it from column definitions.

MySQL

Toasty generates ENUM('a', 'b', 'c') as the column type. There is no standalone named type. When the same Rust enum is used in multiple tables, each table gets its own inline ENUM(...) definition.

SQLite

SQLite has no native enum type. Toasty stores the discriminant as a TEXT column with a CHECK constraint that restricts values to the declared labels:

CREATE TABLE tasks (
    id INTEGER PRIMARY KEY,
    status TEXT NOT NULL CHECK (status IN ('pending', 'active', 'done'))
);

This gives database-level validation while remaining compatible with SQLite’s type system.

DynamoDB

DynamoDB has no column type system or constraint mechanism. Toasty stores the discriminant as a string attribute. Validation happens at the Toasty application level only — the database itself accepts any string value.

Generated SQL Schema

PostgreSQL

Toasty creates a PostgreSQL enum type named after the Rust enum in snake_case:

#![allow(unused)]
fn main() {
#[derive(toasty::Embed)]
enum OrderState {
    #[column(variant = "new")]
    New,
    #[column(variant = "shipped")]
    Shipped,
    #[column(variant = "delivered")]
    Delivered,
}
}
CREATE TYPE order_state AS ENUM ('new', 'shipped', 'delivered');

The discriminant column uses the enum type:

#![allow(unused)]
fn main() {
#[derive(toasty::Model)]
struct Order {
    #[key]
    #[auto]
    id: i64,
    state: OrderState,
}
}
CREATE TABLE orders (
    id BIGSERIAL PRIMARY KEY,
    state order_state NOT NULL
);

Customizing the PostgreSQL type name

To specify a custom name for the PostgreSQL enum type, use enum with a name argument in the #[column(type = ...)] attribute:

#![allow(unused)]
fn main() {
#[derive(toasty::Embed)]
#[column(type = enum("order_status"))]
enum OrderState {
    New,
    Shipped,
    Delivered,
}
}
CREATE TYPE order_status AS ENUM ('new', 'shipped', 'delivered');

Without this attribute, Toasty derives the type name from the Rust enum name in snake_case.

MySQL

MySQL enum types are defined inline on the column:

CREATE TABLE orders (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    state ENUM('new', 'shipped', 'delivered') NOT NULL
);

The enum("name") syntax is ignored on MySQL since there is no standalone type to name.

SQLite

SQLite uses a TEXT column with a CHECK constraint:

CREATE TABLE orders (
    id INTEGER PRIMARY KEY,
    state TEXT NOT NULL CHECK (state IN ('new', 'shipped', 'delivered'))
);

Data-carrying enums

Data-carrying enums work the same way on all backends. The discriminant column uses the enum representation; variant fields remain as separate nullable columns:

#![allow(unused)]
fn main() {
#[derive(toasty::Embed)]
enum ContactMethod {
    #[column(variant = "email")]
    Email { address: String },
    #[column(variant = "phone")]
    Phone { country: String, number: String },
}
}

PostgreSQL:

CREATE TYPE contact_method AS ENUM ('email', 'phone');

CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    contact contact_method NOT NULL,
    contact_email_address TEXT,
    contact_phone_country TEXT,
    contact_phone_number TEXT
);

MySQL:

CREATE TABLE users (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    contact ENUM('email', 'phone') NOT NULL,
    contact_email_address TEXT,
    contact_phone_country TEXT,
    contact_phone_number TEXT
);

SQLite:

CREATE TABLE users (
    id INTEGER PRIMARY KEY,
    contact TEXT NOT NULL CHECK (contact IN ('email', 'phone')),
    contact_email_address TEXT,
    contact_phone_country TEXT,
    contact_phone_number TEXT
);

Migrations

Creating a new enum

When a model with a string-label enum is first migrated, Toasty issues the appropriate DDL.

PostgreSQL:

CREATE TYPE status AS ENUM ('pending', 'active', 'done');
CREATE TABLE tasks (
    id BIGSERIAL PRIMARY KEY,
    status status NOT NULL
);

MySQL:

CREATE TABLE tasks (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    status ENUM('pending', 'active', 'done') NOT NULL
);

SQLite:

CREATE TABLE tasks (
    id INTEGER PRIMARY KEY,
    status TEXT NOT NULL CHECK (status IN ('pending', 'active', 'done'))
);

Label ordering

Database enum types have a declaration order that affects ORDER BY behavior. Toasty manages this order with two rules:

  1. Initial creation: Labels are ordered by the Rust enum’s variant declaration order.
  2. Subsequent migrations: Toasty preserves the existing label order from the previous schema snapshot. New variants are appended at the end. Reordering variants in the Rust source does not trigger any DDL and does not change the database label order.

This means the label order is a one-time decision made at creation. If you need to change the order later, you must do so manually outside of Toasty.

Adding a variant

Adding a new variant to the Rust enum:

#![allow(unused)]
fn main() {
// Before
enum Status { Pending, Active, Done }

// After
enum Status { Pending, Active, Done, Cancelled }
}

New variants are appended after all existing labels, regardless of where they appear in the Rust enum definition.

PostgreSQL:

ALTER TYPE status ADD VALUE 'cancelled';

MySQL:

ALTER TABLE tasks MODIFY COLUMN status
    ENUM('pending', 'active', 'done', 'cancelled') NOT NULL;

SQLite:

SQLite does not support ALTER TABLE ... ALTER COLUMN. Toasty uses its existing table recreation strategy (create new table, copy data, drop old, rename) to update the CHECK constraint with the new label list.

MySQL requires rewriting the full enum definition on every change. Both MySQL and SQLite rewrites are handled automatically, preserving the existing label order and appending the new label at the end.

Renaming a variant

Toasty does not support renaming enum variant labels. Changing a variant’s #[column(variant = "...")] label is a migration error. To rename a label, add the new variant, migrate existing data manually, then remove the old variant (once variant removal is supported).

Removing a variant

Toasty does not support removing enum variants. Removing a variant from the Rust enum while the label still exists in the database schema is a migration error. Destructive schema changes like this require a broader design for handling data loss scenarios and are out of scope for this feature.

Converting from integer discriminants

Switching an existing enum from #[column(variant = N)] (INTEGER) to string labels requires a migration that converts the column.

PostgreSQL:

CREATE TYPE status AS ENUM ('pending', 'active', 'done');
ALTER TABLE tasks
    ALTER COLUMN status TYPE status USING (
        CASE status
            WHEN 1 THEN 'pending'
            WHEN 2 THEN 'active'
            WHEN 3 THEN 'done'
        END
    )::status;

The integer-to-label mapping comes from the previous schema snapshot stored in the migration state.

MySQL:

ALTER TABLE tasks MODIFY COLUMN status
    ENUM('pending', 'active', 'done') NOT NULL;

MySQL’s MODIFY COLUMN handles the type change. For integer conversions, Toasty issues an intermediate step to map integers to their label strings before converting the column type.

Converting from plain string to native enum

Switching from #[column(type = text)] (plain string) to native enum storage (removing the type override) requires converting the column.

PostgreSQL:

CREATE TYPE status AS ENUM ('pending', 'active', 'done');
ALTER TABLE tasks
    ALTER COLUMN status TYPE status USING status::status;

MySQL:

ALTER TABLE tasks MODIFY COLUMN status
    ENUM('pending', 'active', 'done') NOT NULL;

SQLite uses its table recreation strategy to replace the TEXT column with a TEXT + CHECK column.

Querying

The query API is the same regardless of discriminant type. Toasty handles the type casting internally:

#![allow(unused)]
fn main() {
// All of these work identically across all discriminant types
Task::filter(Task::fields().status().eq(Status::Active))
Task::filter(Task::fields().status().is_pending())
Task::filter(Task::fields().status().ne(Status::Done))
Task::filter(Task::fields().status().in_list([Status::Pending, Status::Active]))
}

SQL generated for queries

Queries compare against the enum label as a string literal:

-- .eq(Status::Active)
SELECT * FROM tasks WHERE status = 'active';

-- .in_list([Status::Pending, Status::Active])
SELECT * FROM tasks WHERE status IN ('pending', 'active');

This works across all backends. On PostgreSQL and MySQL the database casts the string literal to the enum type automatically. On SQLite and DynamoDB the column is already a string.

Ordering

Toasty does not support ordering comparisons (>, <, etc.) on enum fields. The query API provides eq, ne, in_list, and variant checks only.

PostgreSQL and MySQL define a sort order for enum values based on their position in the type definition, not alphabetically. SQLite and DynamoDB sort enum columns as plain strings (lexicographic). Toasty does not expose or manage this ordering. Users who query the database directly should be aware that ORDER BY behavior on enum columns varies by backend.

Inserting

Inserts supply the label as a string literal on all backends:

INSERT INTO tasks (status) VALUES ('pending');

Compile-Time Validation

ConditionResult
All string or default labelsValid (native enum storage)
#[column(type = text)] or #[column(type = varchar)]Valid (plain string storage)
#[column(variant = N)] with integersValid (integer storage)
Mix of integer and string variant valuesCompile error
Duplicate labels (including derived defaults)Compile error
Empty string label #[column(variant = "")]Compile error
Label longer than 63 bytesCompile error (PostgreSQL’s NAMEDATALEN limit)

Portability

Native enum storage works across all backends. Each backend uses its best available representation (see Database Support). You can develop against SQLite locally and deploy to PostgreSQL or MySQL without changing the enum definition.

The difference between native enum storage and plain string storage (#[column(type = text)]) is that native enum adds database-level validation where the backend supports it. The stored values are string labels in both cases — there is no data incompatibility between them.

Shared enum types

Multiple models can reference the same enum.

On PostgreSQL, Toasty creates the CREATE TYPE once and reuses it across tables:

#![allow(unused)]
fn main() {
#[derive(toasty::Embed)]
enum Priority { Low, Medium, High }

#[derive(toasty::Model)]
struct Task {
    #[key] #[auto] id: i64,
    priority: Priority,
}

#[derive(toasty::Model)]
struct Bug {
    #[key] #[auto] id: i64,
    priority: Priority,
}
}

PostgreSQL:

CREATE TYPE priority AS ENUM ('low', 'medium', 'high');

CREATE TABLE tasks (
    id BIGSERIAL PRIMARY KEY,
    priority priority NOT NULL
);

CREATE TABLE bugs (
    id BIGSERIAL PRIMARY KEY,
    priority priority NOT NULL
);

MySQL:

CREATE TABLE tasks (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    priority ENUM('low', 'medium', 'high') NOT NULL
);

CREATE TABLE bugs (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    priority ENUM('low', 'medium', 'high') NOT NULL
);

Toasty tracks that the PostgreSQL type already exists and does not attempt to create it twice during migrations. On MySQL each table carries its own inline definition.

Examples

Unit enum with defaults

#![allow(unused)]
fn main() {
#[derive(Debug, PartialEq, toasty::Embed)]
enum Color {
    Red,
    Green,
    Blue,
}

#[derive(Debug, toasty::Model)]
struct Widget {
    #[key]
    #[auto]
    id: i64,
    name: String,
    color: Color,
}
}

PostgreSQL:

CREATE TYPE color AS ENUM ('red', 'green', 'blue');

CREATE TABLE widgets (
    id BIGSERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    color color NOT NULL
);

-- Insert
INSERT INTO widgets (name, color) VALUES ('Sprocket', 'red');

-- Query
SELECT * FROM widgets WHERE color = 'green';

Unit enum with explicit labels

#![allow(unused)]
fn main() {
#[derive(Debug, PartialEq, toasty::Embed)]
enum Status {
    #[column(variant = "pending")]
    Pending,
    #[column(variant = "active")]
    Active,
    #[column(variant = "done")]
    Done,
}

#[derive(Debug, toasty::Model)]
struct Task {
    #[key]
    #[auto]
    id: i64,
    title: String,
    status: Status,
}
}

PostgreSQL:

CREATE TYPE status AS ENUM ('pending', 'active', 'done');

CREATE TABLE tasks (
    id BIGSERIAL PRIMARY KEY,
    title TEXT NOT NULL,
    status status NOT NULL
);

MySQL:

CREATE TABLE tasks (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    title TEXT NOT NULL,
    status ENUM('pending', 'active', 'done') NOT NULL
);

Unit enum with plain string storage

#![allow(unused)]
fn main() {
#[derive(Debug, PartialEq, toasty::Embed)]
#[column(type = text)]
enum Status {
    #[column(variant = "pending")]
    Pending,
    #[column(variant = "active")]
    Active,
    #[column(variant = "done")]
    Done,
}
}
-- Same on all SQL backends
CREATE TABLE tasks (
    id ... PRIMARY KEY,
    status TEXT NOT NULL
);

No enum type or CHECK constraint is created. The column is a plain TEXT.

Data-carrying enum

#![allow(unused)]
fn main() {
#[derive(Debug, PartialEq, toasty::Embed)]
enum ContactMethod {
    #[column(variant = "email")]
    Email { address: String },
    #[column(variant = "phone")]
    Phone { country: String, number: String },
}

#[derive(Debug, toasty::Model)]
struct User {
    #[key]
    #[auto]
    id: i64,
    name: String,
    contact: ContactMethod,
}
}
#![allow(unused)]
fn main() {
// Create
let user = User::create()
    .name("Alice")
    .contact(ContactMethod::Email { address: "alice@example.com".into() })
    .exec(&mut db)
    .await?;

// Query
let email_users = User::filter(User::fields().contact().is_email())
    .exec(&mut db)
    .await?;

// Update
user.update()
    .contact(ContactMethod::Phone {
        country: "US".into(),
        number: "555-0100".into(),
    })
    .exec(&mut db)
    .await?;
}