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 definition | Storage 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 integers | INTEGER 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 variant | Default 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:
| Backend | Representation | Validation |
|---|---|---|
| PostgreSQL | CREATE TYPE ... AS ENUM (named type) | Database rejects invalid values |
| MySQL | Inline ENUM('a', 'b', 'c') column type | Database rejects invalid values |
| SQLite | TEXT column + CHECK constraint | Database rejects invalid values |
| DynamoDB | String attribute | No 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:
- Initial creation: Labels are ordered by the Rust enum’s variant declaration order.
- 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
| Condition | Result |
|---|---|
| All string or default labels | Valid (native enum storage) |
#[column(type = text)] or #[column(type = varchar)] | Valid (plain string storage) |
#[column(variant = N)] with integers | Valid (integer storage) |
| Mix of integer and string variant values | Compile error |
| Duplicate labels (including derived defaults) | Compile error |
Empty string label #[column(variant = "")] | Compile error |
| Label longer than 63 bytes | Compile 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?;
}