Skip to content

Performance ​

Every client plans statements in the calling process and executes them through its native driver. The current client overhead check is measured by make perf-check for the Go and PHP hot paths below; the command fails when the required database environment is missing.

Measurement environment for the driver results: Apple M3 Pro, macOS, MySQL 8.4.11 local Unix socket (/tmp/mysql.sock), orm_bench.author 100,000 rows, one connection, p50. Raw data: docs/operations/perf-raw-go-native.txt, docs/operations/perf-raw-rust-native.txt, docs/operations/perf-raw-rust-native-2.txt.

1. Native driver baseline ​

Prepared statements are reused on one connection. The values measure the drivers without the ORM.

| Workload | Go database/sql | Rust sqlx | PHP PDO | PHP extension | TypeScript | Python | |---|---😐---😐---😐---😐---😐 | Single row by PK (25 columns, AES 2) | 38.0µs | 81.4µs | 30.5µs | not measured | not measured | | 100-row list | 448µs | 472µs | 446µs | not measured | not measured | | INSERT | 175µs | 191µs | 181µs | not measured | not measured | | Four-level relation (20 parents + 3 child IN queries, ≤200 rows + assembly) | 6.43ms | 6.68ms | 7.45ms | not measured | not measured |

2. Executor rules ​

F1 — A pooled client prepares a statement text once per connection and reuses it; a short-lived connection sends each statement in one round trip. In Go, QueryContext(args) without a prepared statement (prepare, execute, and close: three round trips) takes 100µs for a PK row and 38µs with a cached statement. The Go client (pgx statement cache), the Rust client (sqlx) and the TypeScript client (pg named statements) keep a pool whose connections outlive a request, so each connection prepares a statement text once and then binds and executes it with the server plan it keeps; the Python client keeps a pool too, and psycopg, its driver, prepares a statement text on a connection after its fifth run there, while PHP keeps one connection per Db. A PHP connection lives for one request, so a prepare it keeps is never reused, and the PHP client sends each statement with its binds in one round trip (F4). Server transactions of the model_statements page of server_transactions (tests/events/vectors.json, 8 statements of 5 texts on PostgreSQL), first run on a new connection / second run: Go 13 / 8 (pgx sends Parse and Describe in a round trip of their own), Rust 13 / 8 (sqlx 0.9 prepares in a round trip of its own; it has no single-round-trip mode), TypeScript 8 / 8 (pg sends the Parse with the first Bind and Execute), PHP 8 / 8. Measured basis (Go, PostgreSQL, the bench database, a page of GetBySeq, GetsByServiceSeqAndIsClose, GetCountByServiceSeq and a 10-row list; a pool of 4 connections and 4 workers, 800 to 1600 pages after 200 warm-up pages, three rounds on a loaded machine): against the pgx exec query mode, which sends every statement unprepared in one round trip, the statement cache was 24 to 44 % faster at the steady-state p50 (5.5 to 6.3 ms against 7.8 to 8.9 ms), used 22 to 31 % less backend CPU per page (2.2 to 2.5 ms against 2.9 to 3.0 ms) and served 20 to 33 % more pages per second, while the first page on a new connection was 0 to 9 % slower (5.1 to 8.1 ms against 5.1 to 7.6 ms). The same comparison on MySQL (driver-side interpolation against server-side prepares) stayed within the noise of the machine, and the Go MySQL client, the Rust MySQL client and the TypeScript MySQL client keep their server-side prepares, while the PHP client (emulated PDO prepares) and the Python client (PyMySQL) interpolate the binds on the client.

F2 — The sqlx PK latency is the driver's own cost. sqlx reads a PK row in about 80µs, twice Go and PDO, with pool size 1 and a dedicated connection. The difference comes from tokio task switching and protocol parsing.

F3 — An executor does not use a failed sqlx try_get for flow control. A failed try_get formats an error for every cell. Reading integer columns as i64 and retrying unsigned columns as u64 raised a 100-row read from 378µs to 615µs p50 and 1.6ms p90. The executor branches on the column type name and reads signed and unsigned values once.

F4 — PHP sends each statement in one round trip. A PHP connection lives for one request, which usually executes a statement shape once, so the cold path decides. On MySQL the client fixes PDO::ATTR_EMULATE_PREPARES = true. Cold path p50 (off → on): PK 72→48µs, IN(8) 107→75µs, 100 rows 460→382µs. Warm path p50: PK 33→49µs, 100 rows 435→400µs. Types (IP strings, JSON, floats, booleans) and conformance output are identical in both modes. On PostgreSQL the client sets Pdo\Pgsql::ATTR_DISABLE_PREPARES = true: the statement and its binds go to the server as one unnamed statement, with the same parameters and type inference as a named prepare, and without the separate prepare round trip and its implicit transaction for every statement text of the request. The server transactions per statement are checked by server_transactions of tests/events/vectors.json.

F5 — PHP keeps the PDO::FETCH_NUM array as row storage. Fetching rows one by one measured about 511µs for 100 rows against about 425µs for fetchAll.

3. Regression check (make perf-check) ​

The check measures the generated client and an equivalent native result in one process: 100 warm-up pairs, then 1,000 adjacent pairs whose order alternates, and it compares the median of the per-pair client/native ratios with the bound. Load that slows one pair slows both of its sides, so the median ratio does not follow the machine load. Each check also runs beside one busy process per CPU (TestHotPathGateUnderLoad, and ORM_PERF_CPU_LOAD=1 for the PHP check). Both sides run the same SQL text: before the checks, make perf-check runs TestNativeStatementsEqualClient, which compares the Go native statements with the statements the generated client runs, and TestNativeWorkloadsRead, which runs every native read workload against the bench database. The native side of every client scans typed values; the PHP baseline decodes the cells and runs the same typed conversion into row values as the client's assembly, without the client machinery, so the ratio measures that machinery and not the typed conversion itself. Constructing a model object is client work measured at about 0.1µs per row and stays on the client side of the ratio. A median ratio above its bound is reported as a warning (a WARNING line, the CI summary and a GitHub warning annotation); performance is measured and reported and never fails a check (AGENTS.md).

ClientPK bound100-row boundCheck
Go1.351.25packages/go/bench TestHotPathGate with the build tag perfgate
PHP1.351.25packages/php/tests/PerfGateTest.php

PERF-1 — Feature coverage runs the same two workloads (PK and a descending 100-row list) through each of the six native clients—Go, PHP library, PHP extension, TypeScript, Python and Rust—on MySQL, PostgreSQL and SQLite. Each database-specific entrypoint uses the exact ORM_FEATURE_DSN supplied for that coverage item, which is also the database scope read by the independent state reader. The PHP extension entrypoint verifies that Model::gets is internal before running the workload. Each case reports native and client p50, their ratio, process CPU, wall time, sample count and machine; an above-reference measurement is diagnostic and never fails correctness. The fixed references of these feature workloads are recorded for every client, database and workload in contracts/performance-references.json (section 8), from check run orm_check_33189_3c995a77 of commit 97b4566f4. The recorded Go and PHP MySQL bounds above apply only to the separate make perf-check workloads and are unchanged.

The DB-free hot_path_read_correctness owner case independently checks the SQL projection and binds and the expected PK and ordered 100-row results on a self-owned SQLite fixture in every client. It does not time the queries and does not replace the three-database measurement cases.

The separate Go and PHP checks under make perf-check read the seeded MySQL bench database from ORM_BENCH_MYSQL_DSN; the Go check builds only with the tag perfgate. Feature coverage instead supplies its selected database DSN through ORM_FEATURE_DSN. A check fails when its required DSN is absent; neither path substitutes an unrequested local or alternate database.

With the converted baseline, PHP 8.4.25 on the local socket measures a PK ratio of 1.20–1.31 and a 100-row ratio of 1.06–1.12 across runs; the PK ratio is dominated by the fixed request build and plan cost of about 10.5µs per query.

The ratio varies by hardware and approaches 1 as round-trip time increases. A bound is the reference value of the warning; a change requires a measurement and an update to this page.

4. Rust MySQL driver comparison ​

One release process compared sqlx 0.9 with mysql_async 0.37.1 using one connection per pool, identical prepared SQL and binds, the same typed four-field result, 200 warmup operations, and 1,000 measured operations. Each paired query result was equal. Environment: Apple M3 Pro, MySQL 8.4 local Unix socket, 2026-09-12.

Workloadsqlx meanmysql_async meanRatio (mysql_async/sqlx)
Primary-key row58.840µs61.825µs1.051
100-row list1.180ms1.113ms0.943

Run cargo run --release --locked --bin driver_compare -- 1000 in benchmarks/rust. The iteration count is a required argument of native, driver_compare and client_bench; a missing argument or a value that is not an integer of at least the program's minimum (3, 10 and 1) ends the program with status 1 before it connects. A driver replacement requires a measured 2x improvement. Neither workload meets it, so the Rust client keeps sqlx. make rust-driver-check runs driver_compare and the sqlx baseline native with a few iterations against the seeded bench database named by ORM_BENCH_MYSQL_DSN and fails unless both read every workload's rows and exit with status 0; latency is not a CI pass condition.

5. Latest regression-check run ​

On 2026-09-27, make perf-check passed with the seeded MySQL bench database. The Go check measured PK 49.0µs client versus 46.0µs native (ratio 1.08) and a 100-row list 388.0µs versus 321.0µs (ratio 1.21); under CPU load it measured 40.0µs versus 37.0µs (1.07) and 363.0µs versus 298.0µs (1.21). The PHP check measured PK 81.0µs versus 72.0µs (1.13) and 813.8µs versus 712.9µs (1.14); under CPU load it measured 65.3µs versus 56.5µs (1.15) and 1836.2µs versus 1569.8µs (1.15). Every value stayed within the bounds in §3.

6. Decision summary ​

IDDecisionBasis
F1Pooled clients prepare once per connection and reuse; PHP sends one round trip per statement§2
F2sqlx PK latency is intrinsic driver cost§1, §2
F3No failed sqlx try_get for flow control§2
F4PHP: one round trip per statement (MySQL emulated prepares, PostgreSQL ATTR_DISABLE_PREPARES)§2
F5PHP rows stay FETCH_NUM arrays§2
D1Rust keeps sqlx§4

7. Bench databases ​

scripts/bench-db.mts recreates the databases named by BENCH_MYSQL_DSN, BENCH_POSTGRES_DSN and BENCH_SQLITE_DSN, installs contracts/fixtures/bench/bench.dbs through the TypeScript bench model, runs benchmarks/sql/seed.mysql.sql, benchmarks/sql/seed.pg.sql or benchmarks/sql/seed.sqlite.sql, and fills the AES and blind-index columns with the host codecs of the TypeScript client in the authenticated host format.

8. Common client references ​

contracts/performance-references.json declares one fixed ratio for each of the six clients, three databases and two workloads of performance_gate. The existing coverage runner compares the native measurements with that declaration. Measurements above a reference print a warning with the measurement, reference and machine; they do not fail correctness. Raw observations are never promoted automatically.

From one passed check run, record the first attempt's observed ratio for each runtime/database/workload combination. Compare the second attempt with that reference as a diagnostic; add no tolerance and do not use the second attempt to raise the reference. Preserve both attempts and the raw record's path, SHA-256, capture time and machine identity. The Go and PHP make perf-check MySQL bounds in section 3 remain separate and unchanged.

make run-databases TARGETS=feature-performance-check measures, and node scripts/features/performance-reference.mts record <check run id> records the run of .runtime/check/<check run id>/record.json only when the run passed on a clean checkout of HEAD (the record's clean, commit and tree), its feature-performance-check target passed and reported exactly one observation file in var/evidence/performance_gate, created during that target. It records the check run ID and the source commit with the machine and the raw observation digest. Another observation is diagnostic only. The measuring machine runs nothing else during the run: the sessions that share it agree on that window, so concurrent development does not enter a reference.

MIT License · Go / PHP / Rust / TypeScript