MySQL
Toasty’s MySQL driver uses SQLx’s MySQL driver. It covers the SQL feature set Toasty exercises — row locking, native temporal types, inline enum columns, full unsigned 64-bit integers, and both fixed-precision and arbitrary-precision decimals — and integrates with Toasty’s connection pool for retry and recovery.
Enabling the driver
Add the mysql feature to Toasty in Cargo.toml:
[dependencies]
toasty = { version = "0.10", features = ["mysql"] }
Then pass a mysql:// URL to Db::builder:
let db = toasty::Db::builder()
.models(toasty::models!(crate::*))
.connect("mysql://user:pass@localhost:3306/mydb")
.await?;
The URL must include a database name in the path. TLS uses rustls by default.
To select native TLS instead:
[dependencies]
toasty = { version = "0.10", default-features = false, features = ["mysql", "native-tls"] }
Connection URL options
The driver accepts SQLx MySQL connection parameters:
| Parameter | Purpose |
|---|---|
ssl-mode=<mode> | Set the TLS policy: disabled, preferred (default), required, verify_ca, or verify_identity. preferred falls back to plaintext if TLS is unavailable. |
ssl-ca=<path-or-pem> | Set a trusted CA certificate file or inline PEM. |
ssl-cert=<path-or-pem> | Set the client certificate for mutual TLS. |
ssl-key=<path-or-pem> | Set the client private key for mutual TLS. |
socket=<path> | Connect over a Unix socket instead of TCP. |
statement-cache-capacity=<n> | Set the per-connection prepared-statement cache size. Defaults to 100; set to 0 to disable caching. |
charset=<name> | Set the connection character set. Defaults to utf8mb4. |
collation=<name> | Set the connection collation. |
timezone=<value> | Set the session time zone, such as %2B00:00 for +00:00. |
SQLx ignores unrecognized connection parameters. Use the option names listed above.
.connect("mysql://app:secret@db.internal/store\
?ssl-mode=verify_identity&ssl-ca=/etc/ssl/private-ca.pem")
Type mapping
Toasty maps Rust types to MySQL columns as follows. MySQL has a few quirks worth knowing about; the notes below the table call them out.
| Rust type | MySQL column type |
|---|---|
bool | BOOLEAN (a TINYINT(1) alias) |
i8 | TINYINT |
i16 | SMALLINT |
i32 | INTEGER |
i64 | BIGINT |
u8 | TINYINT UNSIGNED |
u16 | SMALLINT UNSIGNED |
u32 | INTEGER UNSIGNED |
u64 | BIGINT UNSIGNED |
f32 | FLOAT |
f64 | DOUBLE |
String | VARCHAR(191) by default; override with #[column(type = varchar(N))] |
Vec<u8> | BLOB |
uuid::Uuid | VARCHAR(36) |
rust_decimal::Decimal (feature) | DECIMAL(p, s) — precision and scale required |
bigdecimal::BigDecimal (feature) | DECIMAL(p, s) — precision and scale required |
jiff::Timestamp (feature) | DATETIME(6), stored in UTC |
jiff::civil::Date (feature) | DATE |
jiff::civil::Time (feature) | TIME(6) |
jiff::civil::DateTime (feature) | DATETIME(6) |
toasty::stmt::IpCidr (feature) | VARCHAR(43) |
toasty::stmt::IpInet (feature) | VARCHAR(43) |
toasty::stmt::MacAddr6 (feature) | VARCHAR(17) |
toasty::stmt::MacAddr8 (feature) | VARCHAR(23) |
Vec<T> (T scalar) | JSON |
Embedded enum | Inline ENUM('a', 'b', ...) column |
Notes on the type mapping
VARCHAR(191) is the default string type. MySQL’s row format
caps the total row size at 65,535 bytes, and utf8mb4 consumes up to
four bytes per character. An indexed VARCHAR column has a per-index
prefix limit of 767 bytes on older InnoDB row formats — 191
characters fits inside that limit with utf8mb4, so the default lets
you index a string field without configuring anything. Override with
#[column(type = varchar(N))] for N up to 65,535 (see
Field Options). The
schema builder rejects larger values.
Full unsigned 64-bit range. MySQL has native unsigned integer
types, so u64 rides BIGINT UNSIGNED and can hold the full
0..=2⁶⁴−1 range. This is the only Toasty backend where u64 is not
capped at i64::MAX.
UUIDs go in VARCHAR(36). MySQL has no native UUID type. Toasty
stores UUIDs as their hyphenated text form. A 16-byte BINARY(16)
column would pack tighter, but VARCHAR(36) is easier to inspect from
a SQL prompt.
jiff::Timestamp maps to DATETIME(6), not TIMESTAMP. MySQL’s
TIMESTAMP only spans 1970-01-01 to 2038-01-19. Toasty uses
DATETIME(6) instead and converts to and from UTC at the driver
layer, so values round-trip as UTC instants without being bound to
the 2038 cutoff.
Decimal requires fixed precision and scale. Unlike PostgreSQL’s
NUMERIC, MySQL’s DECIMAL always has a declared precision and
scale; there is no arbitrary-precision mode. Set them with
#[column(type = decimal(p, s))] when declaring the field.
BigDecimal works natively on MySQL. Toasty rides DECIMAL(p, s) for bigdecimal::BigDecimal here. PostgreSQL falls back to text
for BigDecimal; MySQL is currently the only backend that exchanges
it as a native decimal value over the wire.
Vec<scalar> goes in a JSON
column. Toasty serializes the list to a JSON array at bind time and
parses it back on read. Array predicates (contains, is_superset,
intersects, len, is_empty) lower to MySQL’s JSON_CONTAINS,
JSON_LENGTH, and related functions.
jiff::Zoned stores as TEXT. MySQL has no column type that
carries an IANA zone name alongside an instant, so zoned values
round-trip through text.
Network addresses use bounded text columns. MySQL has no native
network address types. Toasty stores the canonical text form and sizes
each VARCHAR for the longest value of that address family.
Behavior specific to MySQL
Toasty enables these features automatically when the driver is MySQL. No configuration is required.
Full unsigned 64-bit integers. Values up to u64::MAX round-trip
through BIGINT UNSIGNED without truncation. SQLite and PostgreSQL
cap unsigned types at i64::MAX.
Inline enum columns. An embed-tagged Rust enum
maps to a column declared ENUM('variant_a', 'variant_b', ...). There
is no separate named type to maintain; adding a variant emits an
ALTER TABLE ... MODIFY COLUMN against the same column.
Both Decimal and BigDecimal are native. Enable the
rust_decimal feature for rust_decimal::Decimal, the bigdecimal
feature for bigdecimal::BigDecimal, or both. Each maps to
DECIMAL(p, s) with declared precision and scale.
Row-level locking. Generated transactions can
use SELECT ... FOR UPDATE to lock rows for the duration of a
transaction.
Backward pagination.
.paginate(per_page).prev(&db)
walks backwards from a page cursor.
Case-sensitive prefix match. The
.starts_with() filter lowers to
BINARY col LIKE 'prefix%'. Casting the column to BINARY forces a byte
comparison, so the match is case-sensitive regardless of the column’s collation.
A few things that exist on PostgreSQL are absent here:
No targeted upsert. MySQL’s ON DUPLICATE KEY UPDATE reacts to any primary
key or unique-index conflict; it cannot restrict the update to the constraint
named by upsert_by_*. Toasty returns
unsupported_feature instead of updating a row selected by a different
constraint.
No ILIKE. MySQL has no ILIKE operator, so
.ilike() is rejected with an
unsupported_feature error. MySQL’s LIKE case sensitivity is set by the
column’s collation: utf8mb4_unicode_ci and other _ci collations match
case-insensitively, while binary and _bin collations match case-sensitively.
Pick the collation that matches the semantics you want when declaring the
column, then use .like().
No native RETURNING from INSERT or UPDATE. For a single-row insert with
one auto-increment result, Toasty retrieves the exact generated ID with
LAST_INSERT_ID() on the same connection. This lets
Model::create() return a populated model.
MySQL cannot return every generated ID from a multi-row insert. Toasty does not
assume that later IDs are consecutive: a bulk insert that needs
database-generated values is rejected with unsupported_feature before the
statement is sent. Bulk inserts remain available when all returned values are
known before execution, such as models with caller-generated or
client-generated UUID keys. Otherwise, insert each record separately or use a
backend with mutation RETURNING support.
For updates that need returned columns, Toasty performs a follow-up SELECT.
That read is not atomic with the update relative to concurrent writers.
No CTE-driven updates. MySQL does not allow UPDATE inside a
common table expression. The query planner avoids generating those.
Most update patterns do not need them; the engine routes complex
multi-statement updates differently on this backend.
Migrations
The migration generator emits DDL that MySQL can apply inside a
single transaction, and apply_migration wraps each migration in
START TRANSACTION / COMMIT. A failure rolls back the migration
and the bookkeeping row in __toasty_migrations together.
Two MySQL-specific behaviors worth knowing:
Column changes are atomic. MySQL’s ALTER TABLE ... MODIFY COLUMN rewrites name, type, nullability, and default in a single
statement. The migration generator takes advantage of this — a
property change emits one statement, not several. PostgreSQL needs
one statement per property; MySQL does not.
Enum types live inline on the column. Adding a variant emits an
ALTER TABLE ... MODIFY COLUMN against the column whose type is the
enum. There is no separate CREATE TYPE step.
The migration tooling does not yet manage zero-downtime online
migrations on MySQL (pt-online-schema-change-style copy and swap,
gh-ost integration, etc.); migrations assume exclusive access to the
schema for their duration.
Errors and the connection pool
The driver classifies SQLx MySQL errors into Toasty’s typed error variants so the pool and caller can react sensibly.
| MySQL error or condition | Toasty error |
|---|---|
Error 1213 (ER_LOCK_DEADLOCK) | Error::SerializationFailure — retryable. InnoDB rolled back the transaction to break a deadlock; retry the unit of work. |
Error 1792 (ER_CANT_EXECUTE_IN_READ_ONLY_TRANSACTION) | Error::ReadOnlyTransaction — the connection is read-only. |
| Other server errors with a SQLSTATE | Error::DriverOperationFailed |
| Socket / protocol errors (closed connection, pool disconnected) | Error::ConnectionLost |
A ConnectionLost error tells the pool to evict the failed
connection and flips the connection’s internal validity flag so the
pool does not hand it back out. The next acquire pings idle
connections, drops the ones that fail, and opens a fresh slot if
needed — so a backend restart typically costs one failed user query
rather than one per pooled connection. See
Database Setup for the pool
knobs (max_pool_size, pool_pre_ping,
pool_health_check_interval, …) and what they do.
SQLx caches prepared statements per connection (up to 100 by default,
tunable via the statement-cache-capacity URL parameter). The cache
is bound to the connection and is dropped when the connection is
evicted, so it does not cause stale-state issues after a backend
restart.