This page is generated from docs/mysql.md on every deploy, and every Iron example in it is compiled and run in CI. A plain Markdown copy for tools and language models is at /llms/mysql.md; see /llms.txt for the full index.
MySQL
import mysql is a client for MySQL 5.7 and 8.x and MariaDB 10.x and 11.x.
It speaks the MySQL client/server protocol itself, so programs need no
client library: the module brings TLS, the authentication plugins, the text
and binary protocols, prepared statements and a thread-safe connection
pool, and links the same OpenSSL as http.
This guide is published at ironlang.dev/mysql, generated from this file; every Iron example in it is compiled in CI. The module's declarations are in src/stdlib/mysql.iron.
Connecting
import mysql func main() { var conn = Mysql.connect_url("mysql://app:secret@127.0.0.1:3306/shop") if conn.error.failed() { println("connect failed: {conn.error.code} {conn.error.message}") return } println("connected to {conn.server_version()}") }
A connection is a MysqlConn. It owns a socket, so it is nocopy and is
closed (with COM_QUIT) when its binding goes out of scope; close() closes
it earlier. Bind it with var: the methods that talk to the server change
the connection's state. A function that uses a caller's connection takes
conn: *var MysqlConn, and the call passes the binding as is.
Mysql.connect(config) takes a MysqlConfig. Construct one with the host,
port, user, password and database, then adjust the rest:
func main() { var cfg = MysqlConfig("db.internal", 3306, "app", "secret", "shop") cfg.tls = MysqlTls.VerifyIdentity cfg.ca_file = "/etc/ssl/db-ca.pem" cfg.connect_timeout = 3000 cfg.timeout = 5000 var conn = Mysql.connect(cfg) if conn.ok() { println("TLS: {conn.is_tls()}, auth: {conn.auth_plugin()}") } }
| Field | Default | Meaning |
|---|---|---|
tls |
Preferred |
Disabled, Preferred (TLS when offered, unverified), Required (unverified), VerifyCa (chain), VerifyIdentity (chain and host name) |
ca_file |
"" |
PEM trust roots for verification; "" uses the system's |
server_public_key |
"" |
PEM file with the server's RSA key, for the password exchange without TLS |
allow_public_key_retrieval |
false |
fetch that key from the server when none is configured |
allow_cleartext_password |
false |
allow mysql_clear_password without TLS |
collation |
"" |
connection collation; "" is utf8mb4 with the server's default |
connect_timeout |
10000 |
milliseconds for TCP, TLS and authentication |
timeout |
0 |
milliseconds for each command's round trip; 0 is no limit |
multi_statements |
false |
let a query string hold several statements |
max_allowed_packet |
1 GB | the largest packet accepted from the server |
init_command |
"" |
SQL run on each new connection (and after a pool reset), such as SET time_zone = '+00:00'; a failure fails the connection |
Mysql.parse_url(url) returns (MysqlConfig, MysqlError) and
Mysql.connect_url(url) connects with it. The URL is
mysql://user:password@host:port/database?option=value&... (mariadb://
also works); user, password, database and values are percent-decoded, and
an IPv6 host goes in brackets. The options are the fields above:
tls (or ssl-mode) with disabled, preferred, required,
verify_ca or verify_identity; ca, server_public_key,
allow_public_key_retrieval, allow_cleartext_password, collation,
connect_timeout, timeout, multi_statements, max_allowed_packet and
init_command.
Authentication
The client supports caching_sha2_password (the MySQL 8 default, both the
fast path and full authentication), mysql_native_password,
sha256_password and mysql_clear_password, and follows the server's auth
switch requests. Full authentication with caching_sha2_password or
sha256_password sends the password inside TLS, or, on a connection
without TLS, encrypted with the server's RSA public key (from
server_public_key, or fetched when allow_public_key_retrieval is set).
Without either, the client refuses to send the password and reports
IRON_ERR_MYSQL_INSECURE_AUTH (7013) rather than exposing it.
Queries
exec(sql, params) runs SQL with ? placeholders as a prepared statement
over the binary protocol. It is the safe way to include values from outside
the program, and the fast one: each connection prepares a statement once
and keeps it in a cache keyed by the SQL text (256 per connection, least
recently used first out), so a repeated query costs one round trip.
query(sql) runs SQL with the text protocol, and run(sql) runs SQL that
returns no rows and reports only the error.
func main() { var conn = Mysql.connect_url("mysql://app:secret@127.0.0.1:3306/shop") if conn.error.failed() { return } conn.run("CREATE TABLE IF NOT EXISTS users (id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, email VARCHAR(200), born DATE)") val ins = conn.exec("INSERT INTO users (name, email, born) VALUES (?, ?, ?)", [MysqlValue.Text("Ada"), MysqlValue.Null, MysqlValue.Date(Mysql.date(1815, 12, 10))]) if ins.ok() { println("inserted id {ins.last_insert_id()}, {ins.affected_rows()} row") } val res = conn.exec("SELECT id, name, email, born FROM users WHERE name = ?", [MysqlValue.Text("Ada")]) if res.failed() { println("query failed: {res.error.message}") return } var row = 0 while row < res.row_count() { val email = res.text(row, res.col("email")) println("{res.int(row, 0)} {res.text(row, 1)} email={email} born={res.text(row, 3)}") row += 1 } }
Parameters
A parameter is a MysqlValue:
| Variant | Sent as |
|---|---|
Null |
NULL |
Int(Int), UInt(UInt) |
BIGINT, BIGINT UNSIGNED |
Float(Float) |
DOUBLE |
Decimal(String) |
DECIMAL, exactly as written ("12.50") |
Text(String), Bytes(String) |
character string, binary string |
Bool(Bool) |
TINYINT 0 or 1 |
Date(MysqlDateTime), DateTime(MysqlDateTime) |
DATE, DATETIME (with microseconds) |
Time(MysqlTime) |
TIME (negative and over 24 hours allowed) |
Mysql.date(y, m, d) and Mysql.datetime(y, m, d, h, mi, s) build the date
values. Passing more or fewer parameters than the statement has
placeholders is IRON_ERR_MYSQL_PARAM_COUNT (7011).
Reading results
Every result is read from the server before exec or query returns: the
connection is free for the next command at once, and a MysqlResult holds
its rows in one buffer until its binding goes out of scope. row_count()
and column_count() give its shape, col(name) finds a column by name or
label (case-insensitive; -1 when missing), and the readers take a row and
a column index:
| Reader | Returns |
|---|---|
int, uint, float, bool |
Int?, UInt?, Float?, Bool? |
int_or, uint_or, float_or, text_or, bool_or |
the value, or the fallback given as the third argument |
text, bytes |
String?: any value as text (dates as YYYY-MM-DD HH:MM:SS), or the raw bytes |
datetime, time |
MysqlDateTime?, MysqlTime? |
value |
MysqlValue chosen from the column type |
is_null |
Bool |
The short readers return null for SQL NULL and for a value that does not
convert (text to Int, 1.5 to Int, a BIGINT UNSIGNED over the Int
range); check them before use (v != null and v > 0), or read with an
_or form when a default will do (res.int_or(0, 1, 0)). The get_ forms (get_int, get_text, ...) return the value with
a MysqlError that says which: IRON_ERR_MYSQL_NULL (7005),
IRON_ERR_MYSQL_CONVERSION (7006) or IRON_ERR_MYSQL_RANGE (7007). The
text and binary protocols read the same way: text of a DOUBLE or a
DATETIME gives the same string whichever produced it.
func main() { var conn = Mysql.connect_url("mysql://app:secret@127.0.0.1:3306/shop") val res = conn.query("SELECT 41 + 1 AS answer, NULL AS nothing") val (answer, err) = res.get_int(0, res.col("answer")) if err.ok() { println("answer {answer}") } val (_, null_err) = res.get_int(0, 1) println("NULL is error {null_err.code}") match res.value(0, 0) { MysqlValue.Int(v) -> println("an Int: {v}") else -> println("something else") } }
column(i) describes a column: name, org_name, table, org_table,
schema, the wire type and its SQL type_name, flags, decimals,
length and the charset collation id, with is_unsigned(),
is_nullable(), is_primary_key() and is_binary(). affected_rows(),
last_insert_id(), warnings() and info() report what a statement
changed.
Several result sets
With multi_statements set, one query may hold several statements, and a
stored procedure returns a result set per SELECT it runs (through exec
too). The result starts at the first set; next_result() moves to the next
one and returns false after the last. An error stops the batch: the sets
before it stay readable and error describes the failure.
func main() { var cfg = MysqlConfig("127.0.0.1", 3306, "app", "secret", "shop") cfg.multi_statements = true var conn = Mysql.connect(cfg) var res = conn.query("SELECT 1 AS a; SELECT 'two' AS b") println("{res.result_count()} sets, first {res.int(0, 0)}") if res.next_result() { println("second {res.text(0, 0)}") } }
Prepared statements
exec is usually all a program needs. prepare(sql) returns a
MysqlStmt to manage a statement directly: exec(params), reset(),
close(), param_count() and column_count(). A statement belongs to its
connection and must not outlive it; using one after its connection closed
or went back to its pool is IRON_ERR_MYSQL_STMT_CLOSED (7015).
Escaping
Prefer parameters. When SQL text must be built, conn.escape(s) escapes a
string for a quoted literal following the session's NO_BACKSLASH_ESCAPES
mode, conn.quote(s) also adds the quotes, Mysql.escape(s) assumes
backslash escapes, and Mysql.quote_identifier(name) quotes a table or
column name in backticks.
Transactions
begin(), commit() and rollback() return a MysqlError;
in_transaction() reports the server's view.
func transfer(conn: *var MysqlConn, from: Int, to: Int, amount: Int) -> MysqlError { val started = conn.begin() if started.failed() { return started } val debit = conn.exec("UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?", [MysqlValue.Int(amount), MysqlValue.Int(from), MysqlValue.Int(amount)]) if debit.failed() or debit.affected_rows() != 1 { conn.rollback() return debit.error } val credit = conn.exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", [MysqlValue.Int(amount), MysqlValue.Int(to)]) if credit.failed() { conn.rollback() return credit.error } return conn.commit() } func main() { var conn = Mysql.connect_url("mysql://app:secret@127.0.0.1:3306/bank") val err = transfer(conn, 1, 2, 50) println("transfer: {err.code}") }
Connection pools
A service that handles requests concurrently shares an rc MysqlPool. Any
number of tasks may use it at once; each borrows its own connection.
import http func handle(db: rc MysqlPool, connection: HttpConnection) -> Int { val request = HttpConnection.read_request(connection, 65536, 1048576, 5000) var status = 404 var body = "\{\"error\":\"not found\"\}" if request.error == 0 and request.path == "/user" { val id = request.query.replace("id=", "").to_int() val res = db.exec("SELECT name FROM users WHERE id = ?", [MysqlValue.Int(id)]) if res.failed() { status = 500 body = "\{\"error\":\"database\"\}" } elif res.row_count() == 1 { status = 200 body = "\{\"name\":\"{res.text_or(0, 0, "")}\"\}" } } val response = Http.json_response(status, body) val sent = HttpConnection.send_response(connection, response, 5000) HttpConnection.close(connection) return sent } func main() { val (cfg, _) = Mysql.parse_url("mysql://app:secret@127.0.0.1:3306/shop") var options = MysqlPoolOptions(4, 32) options.acquire_timeout = 2000 val db = Mysql.open_pool(cfg, options) if not db.ok() { println("pool: {db.error.message}") return } val listening = Http.listen("127.0.0.1", 8080) if listening.error != 0 { return } while true { val accepted = HttpServer.accept(listening.server, 60000) if accepted.error == 0 { val connection = accepted.connection spawn("request") { return handle(db, connection) } } } }
query, exec and run on the pool borrow a connection for one statement.
acquire() hands out a MysqlConn for several statements, such as a
transaction; it goes back to the pool when its binding goes out of scope,
and a transaction still open then is rolled back.
func move_stock(db: rc MysqlPool, item: Int) -> Bool { var conn = db.acquire() if conn.error.failed() { return false } conn.begin() conn.exec("UPDATE stock SET qty = qty - 1 WHERE item = ?", [MysqlValue.Int(item)]) conn.exec("INSERT INTO moves (item) VALUES (?)", [MysqlValue.Int(item)]) return conn.commit().ok() } func main() { val db = Mysql.open_pool(MysqlConfig("127.0.0.1", 3306, "app", "secret", "shop"), MysqlPoolOptions(1, 8)) println("{move_stock(db, 7)} size={db.size()} idle={db.idle()} in use={db.in_use()}") }
MysqlPoolOptions(min, max) sets the size; min connections open with the
pool, so a bad configuration fails at open_pool. The other fields:
| Field | Default | Meaning |
|---|---|---|
acquire_timeout |
10000 | wait this long for a free connection, then IRON_ERR_MYSQL_POOL_TIMEOUT (7009) |
idle_timeout |
300000 | close connections idle this long, beyond min |
health_check |
30000 | ping a connection idle this long before handing it out (0: never) |
max_lifetime |
1800000 | replace connections older than this |
reset_on_release |
false |
send COM_RESET_CONNECTION when a connection comes back (clears session state, and the statement cache) |
Before handing out an idle connection, the pool checks that the server has
not closed it (a wait_timeout, a restart, KILL): dead connections are
replaced without the caller seeing an error. close() closes idle
connections and refuses new work; connections in use close when they come
back. size(), idle() and in_use() report the counts.
Errors
Every operation returns a MysqlError, directly or in the error field of
what it returns, and nothing panics on a server or network failure.
ok() and failed() test it, and is_connection_error() says whether the
connection is unusable afterwards (a closed connection then answers every
command with IRON_ERR_MYSQL_CLOSED, 7004; open a new one, or let the pool
do it).
code |
server |
Meaning |
|---|---|---|
| 1000 to 4999 or more | true |
the server's error number, with its sqlstate (1045 access denied, 1062 duplicate key, 1064 syntax, 1146 no such table, ...) |
| 1000 to 1099 | false |
network: 1001 refused, 1004 timeout, 1013 connection lost, ... |
| 3000 to 3099 | false |
TLS: 3003 handshake, 3004 certificate or host name verification, ... |
| 7000 to 7099 | false |
MySQL client: protocol (7000), auth (7001), argument (7002), packet too large (7003), closed (7004), NULL (7005), conversion (7006), range (7007), TLS required (7008), pool timeout (7009), pool closed (7010), parameter count (7011), URL (7012), insecure auth (7013), statement closed (7015) |
Limits
- A packet of 16 MB or more is split and reassembled as the protocol
requires, in both directions. Before sending a statement over 1 MB the
client asks the server for its
max_allowed_packetonce and refuses a larger statement with 7003 without breaking the connection. A packet from the server larger than the client'smax_allowed_packetends the connection with 7003. - Results are read completely into memory. Page large queries with
LIMIT, or keepmax_allowed_packetto the size you expect. LOAD DATA LOCAL INFILEis refused (7017), compression is not negotiated, connections are TCP only (no Unix socket or named pipe), and MariaDB'sclient_ed25519plugin is not supported.- A connection is for one task at a time; share a pool across tasks.