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