<p><em>One file. One binary. The protocol your stack already speaks.</em></p><p>There's a particular kind of satisfaction in a release where the headline isn't a new feature. Where nothing was added to the marketing page, no checkbox was ticked — and yet the database that comes out the other side is measurably, sometimes dramatically, better at the thing you actually asked it to do.</p><p>That's 1.6.0.</p><h2>It started with two bug reports that turned out to be one bug</h2><p>Two people came to us from opposite directions. One said joins were slow. The other said <code>ORDER BY ... LIMIT</code> was slow. Different queries, different tables, different workloads.</p><p>Same defect.</p><p>Both paths were paying for the width of a row rather than for what the query actually read. Ask for two columns out of a twelve-column table and you'd still decode all twelve — for every row, every time, whether or not that row survived the next step. On an <code>ORDER BY x LIMIT 100</code>, you'd build a full row and then throw it away because it lost to the top-N heap. On a join emitting forty million rows, you'd allocate per emitted row rather than per key.</p><p>Fixing it once meant rewriting how the row-oriented paths handle rows at all. Here's what fell out, measured on 200,000 rows with both binaries run alternately against the same data file — medians of five, two rounds:</p><p>Query shape 1.5.1 1.6.0 MySQL 8.4 <code>ORDER BY int LIMIT 100</code>, 12-column rows 95 ms 31 ms 3.0× 55 ms <code>ORDER BY text LIMIT 100</code>, 12-column rows 98 ms 39 ms 2.5× 57 ms 1:1 join on a PK, <code>COUNT(*)</code> 492 ms 150 ms 3.3× 91 ms 1:N join emitting 40M rows 33 298 ms 1 130 ms 29× 535 ms join + <code>ORDER BY ... LIMIT 100</code> 514 ms 157 ms 3.3× 31 ms <code>ORDER BY</code> with no <code>LIMIT</code> (control) 1 951 ms 1 964 ms — 1 869 ms <code>COUNT(*)</code> scan (control) 3.0 ms 3.0 ms — 9.7 ms</p><p>The controls matter as much as the wins. An unbounded sort and a plain scan didn't move a millisecond — which is how you know nothing was quietly traded away to buy those numbers.</p><p>And note the last column. We're not going to pretend: MySQL is still ahead of us on some of these join shapes. 91 ms versus our 150 ms. 31 ms versus our 157 ms. Forty years of join planner refinement is a real thing and we respect it. What changed in 1.6.0 is that we went from embarrassing to competitive — a 29× gap closing to within 2×, and on one shape we now sit comfortably ahead.</p><h2>The bug we found by not trusting ourselves</h2><p>While benchmarking joins against MySQL, we noticed something worse than slowness. A join condition spanning both tables — <code>ON a.v + b.v = 4</code> — returned 2,500,000 rows where MySQL returned 1,250,000.</p><p>Wrong results. Silently. No error, no warning.</p><p>The cause was subtle in the way that genuinely dangerous bugs always are. Side attribution resolved column references through the resolver's bare-name fallback, so <code>b.v</code> matched the left schema's <code>a.v</code>, the whole sum got judged left-only, and the partner was hashed under the constant <code>4</code>. Everything downstream was internally consistent and externally wrong.</p><p>We didn't find it because someone reported it. We found it because we ran a join battery differentially against MySQL 8.4 while working on something else entirely. That's the part worth taking away: the fix was one afternoon, the detection method is the durable asset. Correctness in this project is now guarded by wire tests, property tests, fuzzing, crash-recovery tests, concurrent soak and chaos runs — and, increasingly, by asking another database what it thinks the answer is.</p><h2>A class of query that used to just say no</h2><p>Before 1.6.0, a non-equi join — <code>ON a.id &lt; b.id</code>, a <code>BETWEEN</code> band join, anything with no equality to hash on — had no key to build a hash table from. The chain declined, the whole product materialised, and you hit the memory ceilings we'd added in 1.4.13.</p><p>Those ceilings had to fill before they could refuse. On 20,000 rows, a three-way <code>ON a.id &lt; b.id</code> under <code>COUNT(*)</code> grew the server to 2,136 MB and then failed after 1.3 seconds. Technically safe. Practically awful.</p><p>Now it's the same unkeyed step a comma cross join uses, with the <code>ON</code> applied per pair and streamed into the spilling sorter. The same query holds the server at its 34 MB idle footprint and answers.</p><p>Honest trade, stated plainly: memory is now bounded, but time is not. When the work is genuinely astronomical you'll hit <code>ELYRASQL_QUERY_TIMEOUT_MS</code> — with your session still alive and usable. We think a timeout is the right control for unbounded work. You may disagree, and you can tune it.</p><h2>Where ElyraSQL actually beats MySQL</h2><p>Let's be specific rather than triumphant. On native Linux CI, all three engines on the same host, 1,000,000 rows:</p><p>Query ElyraSQL PostgreSQL 17 MySQL 8.4 Global aggregation (<code>SUM</code>/<code>AVG</code>/<code>MIN</code>/<code>MAX</code>) 35.9 ms 45.1 162.4 <code>GROUP BY</code>, 100 groups 48.5 ms 75.0 312.2 <code>GROUP BY</code> + top-10, 10k groups 53.5 ms 95.9 344.6 Filtered aggregation 50.5 ms 54.5 229.5 <code>COUNT(*)</code> 25.2 ms 28.7 24.0</p><p>Six times faster than MySQL on grouped aggregation. Two to six times across the aggregation board, and ahead of PostgreSQL too. That's unusual for a row store, and it comes from vectorised scalar and grouped aggregation over flat <code>f64</code> arrays, a compiled filter predicate, parallel clustered scans, and a bounded table-keyspace split.</p><p>Meanwhile the fast paths stay where you want them: PK point lookup 0.26 ms, selective index-NLJ join 0.39 ms, cached vector ANN 0.29 ms, bulk ingest at ~350,000 rows/s.</p><p>So the honest positioning is this: if your workload is analytical — dashboards, reporting, aggregation over millions of rows, mixed with ordinary transactional traffic — ElyraSQL is very likely the better choice, and not by a small margin. If your workload is dominated by large multi-way relational joins, MySQL's planner still has the edge, and you should benchmark before switching. We'd rather you find that out from us than from production.</p><h2>Robustness isn't a feature, it's an absence</h2><p>The whole database is one crash-safe file. Single-writer, multi-reader, ACID. You can <code>BACKUP TO '&lt;path&gt;'</code> hot and consistent while the server keeps serving, or use the offline CLI. There is no cluster of processes to babysit, no separate search engine, no analytics sidecar, no vector extension to keep version-matched.</p><p>When you do want high availability, it's built in rather than bolted on: asynchronous primary → replica streaming, optional semi-sync, and automatic failover via Raft-style leader election.</p><p>The thing we're proudest of in 1.6.0 isn't in the changelog at all. It's that the memory profile is boring. A server that sits at 34 MB idle and stays there while streaming a forty-million-row join is a server you can reason about at 3 AM.</p><h2>Security, and the mistake we decided to make impossible</h2><p>Most databases ship a footgun and document it. We tried to remove it.</p><p>ElyraSQL with no users configured accepts any login — that's normal for local development. But the server refuses to start in that mode if its listener is bound to a non-loopback address. Bind to <code>0.0.0.0</code> with no credentials and it stops and tells you why. This is the single most common way a database ends up naked on the internet, and now it takes a deliberate <code>ELYRASQL_ALLOW_OPEN_AUTH=1</code> to do it. Local dev on <code>127.0.0.1:3307</code> is completely unaffected.</p><p>The replication endpoint is guarded the same way — unauthenticated unless you set <code>ELYRASQL_CLUSTER_SECRET</code>, and refused on a public bind without one.</p><p>Beyond that: <code>mysql_native_password</code> and MySQL 8's <code>caching_sha2_password</code>, passwords never stored in plaintext (only the <code>SHA1(SHA1(password))</code> digest MySQL itself keeps), fresh salt per connection, RSA-OAEP for cleartext channels, TLS throughout. Inter-node traffic — both the replication stream and the Raft control plane — is encrypted and mutually authenticated when the cluster TLS variables are set, with real certificate verification rather than accept-any.</p><p>Persistent accounts, <code>GRANT</code>/<code>REVOKE</code>, <code>SHOW GRANTS</code>, read/write/admin roles. Prometheus <code>/metrics</code>, <code>SHOW PROCESSLIST</code>, a slow-query log.</p><h2>Migrating is deliberately unexciting</h2><p>ElyraSQL speaks the MySQL wire protocol. Laravel, Django, Rails, Node, Go, Rust, DBeaver, Workbench, the <code>mysql</code> CLI — they connect unchanged. Eloquent migrations, models, relationships and transactions run as-is.</p><p>And upgrading within ElyraSQL is just as dull. 1.6.0 has no on-disk format change and no migration step. Your 1.5.x database opens unchanged, and a 1.6.0 database still opens in 1.5.x. Pull the new image, point it at the same volume, done — we upgraded our own test instance this morning in about four minutes, most of which was the compile.</p><p>We bumped the minor rather than the patch for one reason only: non-equi joins now answer queries that previously errored. That's observable behaviour, so it gets a minor. We take semver literally.</p><h2>The honest picture</h2><p>There's a page in our docs called <code>limitations.md</code>. It's long, it's specific, and it's linked from the README on purpose. Cursors buffer their full result set. Materialized views recompute rather than maintain deltas incrementally. <code>RANGE</code>/<code>GROUPS</code> numeric value-offset frames aren't implemented. Triggers don't fire on the upsert variants. Vector ANN pays a one-time index build, which suits read-heavy RAG workloads and punishes write-heavy vector tables.</p><p>We'd rather you read that page before deploying than discover it after. A database earns trust the same way a person does — by being right about the boring things, consistently, and by saying so when it isn't.</p><h2>Try it</h2><pre><code class="language-bash">docker run -d --name elyrasql \
  -p 3307:3307 -v elyrasql-data:/var/lib/elyrasql \
  -e ELYRASQL_USER=root -e ELYRASQL_PASSWORD=secret \
  elyrasql:1.6.0 serve

mysql -h 127.0.0.1 -P 3307 -u root -psecret -e "SELECT VERSION()"
# 8.0.0-ElyraSQL-1.6.0
</code></pre><p>Then run our benchmarks on your own hardware — <code>bench/compare.py</code> runs the identical portable workload against ElyraSQL, MySQL and PostgreSQL, and <code>gh workflow run benchmark.yml</code> reproduces our native-Linux numbers. Our figures are relative, not absolute, and yours will differ. That's the point of publishing the script alongside the table.</p><p>ElyraSQL is MIT licensed. One binary, one file, no second system to operate.</p><p><a target="_blank" rel="noopener noreferrer nofollow" href="https://elyracode.com/docs/sql-server">elyracode.com/docs/sql-server</a></p>