SQL / Server v1.12.4 · stable
Elyra SQL Server · The Database

A SQL server
in a single file .

Elyra SQL Server is a robust, MySQL-compatible engine in Rust. The whole database is one crash-safe ACID file, it speaks the MySQL wire protocol so mysql, DBeaver and any driver work unchanged — with native vector search and parallel OLAP aggregation built in.

mysql — connect with any client
# Start the server on a single file
$ elyrasql serve --data mydb.edb
✓ MySQL protocol on 127.0.0.1:3306

# Connect with the stock mysql client
$ mysql -h127.0.0.1 -P3306
mysql> SELECT id FROM docs
    -> ORDER BY embedding <-> VECTOR '[…]' LIMIT 5;
Download · v1.12.4

Download SQL Server

Self-contained tarballs for Linux and Apple Silicon macOS, or build from source.

All releases

Running a long-lived server? Upgrade to 1.12.4 — under a steady write load its page cache grew for as long as it ran.

The page cache no longer grows without bound under a write workload. A server whose page cache never reached its configured limit grew for as long as it ran — which is every long-lived deployment with a steady write load — and a restart was the only remedy. 1.12.4 moves redb from 2.6.3 to 2.6.4, which fixes it.

Before you upgrade: a correlated subquery inside an aggregate is refused sooner. No on-disk format change; a 1.12.3 database opens in 1.12.4 unchanged. A correlated subquery inside an aggregate expression, such as SUM(CASE WHEN t.col IN (SELECT … WHERE s.k = t.k) THEN 1 ELSE 0 END), is now refused while the statement is planned. It used to be refused only when a row reached it, so it was accepted on an empty table and failed on a populated one. Rewrite it by resolving the keys first and binding them as a list. The uncorrelated form now works, and so does a subquery inside a CASE arm or a function argument, such as SELECT ABS((SELECT -1)); nothing that ran before stops running.

Also on 1.11.4 or earlier? Upgrade to 1.12.0 — a column grant could be read around, a replica ignored grants made after it started, and some aggregates were wrong without an error.

Column grants now hold in every statement that reads. A user granted SELECT(pub) could read the table’s other columns through a set operation — SELECT sec FROM t UNION SELECT 'x' — or copy them out with INSERT INTO mine SELECT sec FROM t, because the column check returned early for anything that was not a single SELECT. And on a replica, a column grant made on the primary after the replica had looked for one was not enforced until a restart, an ALTER TABLE left it serving the old definition, and a trigger could be missing from a follower promoted to leader.

Integer aggregates are exact on every path. The fast columnar paths — two or more aggregates, a GROUP BY on one numeric column, the opt-in column cache — carried numbers as doubles, so past 253 the answers were wrong with no error. Worse, on a table with a BIGINT UNSIGNED column those paths counted every other column’s values twice: SUM(a), COUNT(a) gave 120 and 6 for 60 and 3. Figures from such a table on 1.11.4 or earlier deserve a second look.

Before you upgrade: privileges now work as in MySQL. A new account starts with USAGE only instead of reading every table, and an UPDATE or DELETE with a WHERE needs SELECT on its table. Accounts created before 1.12.0 keep the global SELECT they had, and accounts configured at startup keep their tier — and a --auth user:pass:write account can finally write; it fell back to read before. Anything that creates accounts and expects them to read needs a GRANT now.

Also on 1.11.3 or earlier: 1.11.4 moved rustls to 0.23.45 (RUSTSEC-2026-0285). rustls’s TLS 1.3 handshake accepted handshake messages across encryption-level boundaries. ElyraSQL terminates client TLS with rustls, and the engine’s outbound embedding calls use it too, so every release from 0.9.9 through 1.11.3 shipped an affected version. The advisory was published on 14 September, two days after 1.11.3, which is why that release’s audit was green. That release changed only the lockfiles.

Also on 1.11.0 or earlier? Upgrade — and re-insert any binary data you stored through a prepared statement. 1.11.1 stores binary parameters exactly; before it, they were silently corrupted. A value bound over the binary protocol that was not valid UTF-8 — an image, a hash, ciphertext, anything a driver sends as bytes — was rendered into SQL through from_utf8_lossy, which replaced every invalid byte with U+FFFD and raised no error: seven bytes in, fifteen out. Every client using server-side prepared statements for BLOB columns was affected — PDO without emulated prepares, Go database/sql, JDBC with useServerPrepStmts, Python connectors with prepared=True — silently. Bytes are rendered as a hex literal now. The fix cannot repair what is already on disk: rows written through the old path are corrupted and must be re-inserted, and the installation guide says how to find them.

The same release closes a second hole. A stored procedure whose cursor had no name — DECLARE CURSOR FOR … — hit an unwrap() in the body parser, and under the release build's abort-on-panic (below) that is not a failed statement but the whole process, for every client. The body was parsed only at CALL, so an admin's typo was stored, survived restarts, and detonated on whoever called it — a restart loop. It is parsed and refused at CREATE PROCEDURE now, and property tests hold the body parser to never panicking, which is the invariant abort-on-panic quietly demands of every parser.

1.9.9 closes three independent holes in the replication surface, all reachable in 1.9.8. The first needs no credentials and no handshake: connecting to the replication port returned a full copy of the database. Non-loopback binds were already refused, so the gap was local — any process on the box, or an SSRF payload that could open a TCP connection, could take everything. Second, elyrasql replica had no authentication flags at all and always started open: any username and password logged in as Admin against the full replicated data set. Third, a replica never verified its primary — any host that could accept TCP on the primary's address could impersonate it and feed the replica fabricated tables and tampered rows, which the replica then served to clients as its own.

The fixes are deliberately breaking. Startup is refused without ELYRASQL_CLUSTER_SECRET unless you set ELYRASQL_ALLOW_OPEN_AUTH=1 to say you meant it, the replica subcommand now takes --user/--password like serve, and the handshake changed — so primary and replica must be upgraded together. If a replication port has been reachable by anything you would not hand the database to, upgrading closes the door and does not tell you whether somebody already walked through it.

1.9.8 makes DECIMAL arithmetic exact, and before it your money columns were computed in binary floating point. Division, modulo, ROUND, TRUNCATE, MOD(), AVG and SUM all went through f64, and the values agreed with MySQL only while they stayed small and round. ROUND(1.005, 2) gave 1.00 where MySQL gives 1.01, because 1.005 has no exact binary form and rounding it through a float rounds down. A windowed SUM returned 25.050000000000004 for 25.05. Anything you computed and stored through those functions is worth recomputing — the results change on upgrade, which is the point, and the upgrade note in the installation guide lists the new types alongside them.

Also fixed, and older than any of this: a string bound on a numeric key dropped rows. k > '1.5' on an INT primary key coerced the bound to 2 and kept the strict >, so row 2 was never returned. Bound coercion already compensated for that rounding, but only for a FLOAT or DECIMAL literal — a string one was assumed to be value-preserving. Reachable through any scalar subquery whose result is rendered as text.

1.9.6 returns two wrong answers that a Laravel application hits by default. A string literal containing a backslash-escaped quote — which is what PDO and mysql_real_escape_string emit — was mis-parsed before it reached the parser, so SELECT 'a\'b!c' returned a'b(NOT (c)) with no error at all. And affected-row counts reported rows matched rather than rows changed, so an INSERT … ON DUPLICATE KEY UPDATE that updated a row said 1 where MySQL says 2, and one that changed nothing said 1 where MySQL says 0. That 1-versus-2 distinction is the documented way a client tells an insert from an update, which is how updateOrCreate decides what it just did.

Also in 1.9.6: a boolean used as a number returned DOUBLE where MySQL returns BIGINT, and MySQL error codes were recovered by matching prefixes of the human-readable message — so rewording an error changed the code an ORM branches on. Release binaries now abort on panic rather than unwinding, because a panic while holding one of the server's mutexes left it poisoned for the life of the process: a server that still accepted connections and passed a health check while failing every query. A clean crash is the better failure, but it means the process must be supervised — the packaged systemd unit already is, and the deployment guide now covers containers and orchestrators.

1.9.5 closed seven holes, four of them a guarantee the server reported and did not have. Column-level grants were enforced only in the top-level FROM, so a restricted column could be read through a derived table, a CTE, a scalar subquery, a set-operation arm, or plain GROUP BY secret — and grants inherited through a role were not resolved at all. If you rely on column grants to keep a column from someone, assume it did not. A replica could acknowledge a log position it had never been sent, satisfying a --sync-replicas barrier without the write being replicated. Cluster TLS ran unencrypted when half-configured, described on the replication card below. A ? inside a SQL comment counted as a parameter, so a bound value containing a newline could end the comment and execute as SQL. And ai_embed() resolved before the permission checks, so a user who could not run the statement could still make the server issue an outbound request carrying your provider API key. Login lockout is gone rather than fixed: locking an account on repeated failures let unauthenticated traffic deny service to a valid user, and rate limiting that cannot be abused that way is not built yet.

Older than 1.9.2? Three more that returned or committed the wrong thing silently: LEFT JOIN … WHERE nullable IS NULL returned every row when the ON had more than one condition, SET autocommit=0 was accepted and discarded so ROLLBACK did nothing, and before 1.9.0 NATURAL JOIN produced a cartesian product while a database qualifier was ignored, so UPDATE otherdb.t … modified the local table and reported success. Recompute anything derived from that anti-join, and treat work you thought was rolled back as committed.

No migration at any point — a 1.5.x database opens unchanged. Every behaviour change that can stop a deployment starting or alter what it reports is listed in the installation guide's upgrade notes, which is the list to read before upgrading rather than this one.

Upgrading from 1.4.x: read this first

1.5.0 made utf8mb4_0900_ai_ci the default collation, which changes which rows a query returns for non-ASCII text — and the bytes text is stored under. An existing database is migrated on first open: text index entries are rebuilt and text primary keys re-keyed before any connection is accepted. Databases whose indexed text is pure ASCII are not rewritten.

The migration writes in batches, so a large table cannot exhaust memory at startup, and it is idempotent: an already re-keyed row encodes to the key it is already stored under, and the version marker is written only once every table is done — so an interrupted upgrade simply resumes on the next start.

Take a backup first. Downgrading to 1.4.x afterwards is not supported. Details in the installation guide.

S.01 — What it does

One file,
the MySQL wire protocol.

Full DDL/DML, joins, correlated subqueries, CTEs (incl. WITH RECURSIVE), window functions, set operations and transactions — plus AI-native hybrid search, in-SQL embeddings, vector search and parallel OLAP, in a single ACID file.

Single-file, ACID

The whole database is one crash-safe file (*.edb) with multi-version concurrency — a single-writer / multi-reader model. “Crash-safe” is tested, not asserted: a robustness scenario repeatedly SIGKILLs the server and checks that acknowledged commits survived, that a mid-write kill with six concurrent writers left no torn transaction, and that totals are conserved across ~3000 concurrent transfers.

Or no server at all

Since 1.10.0 the same engine opens an .edb file in-process, as a library — no server, no socket, no wire protocol — with a C ABI (elyra-embed-capi) for anything that reaches Rust through an FFI. The SQL is identical because it is the same engine: elyra-engine sits on the storage layer and nothing above it, so this is a second entry point rather than a second implementation, and a file written in-process is served over MySQL by elyrasql serve and read back with the same values. It is the SQLite shape — one file, no daemon — with the vector search and OLAP surface the server has. The downloads on this page are still the server; the embedded engine is a library you build against.

Serve it to an agent

elyrasql mcp is a Model Context Protocol server over stdio, so an AI agent introspects the schema and runs SQL with no server, port or driver in the way — the embedded shape again, pointed at a model rather than a driver. It is read-only by default, and refuses a write through the engine’s own privilege check rather than by scanning the SQL for dangerous words, because keyword-matching is not a way to refuse them reliably. --allow-writes grants Write and never Admin: INSERT, UPDATE and DELETE become possible while DROP TABLE, schema changes and GRANT stay refused. Results are capped at 200 rows and always say when they were, because a model that cannot tell a partial answer from a complete one will reason from the partial one — and DECIMAL stays a string so its scale survives a model reading prices.

MySQL wire protocol

A first-party wire layer with TLS (rustls), MySQL 8’s caching_sha2_password and native prepared statements. Connect with the mysql CLI, DBeaver or any driver — a full Laravel/Eloquent stack runs cleanly, plus PDO, Python, Node — and Rust (sqlx) since 1.11.2. Until then the docs said sqlx worked out of the box; it did not. sqlx opens every connection with a statement ElyraSQL refused — a scalar subquery as a SET value, and time_zone as a session variable — so no sqlx application got as far as its first query, including anything generic over AnyPool, which cannot turn the statement off. Found when Grove tried to bundle ElyraSQL and its convert tool could not reach it. Both halves are accepted now. That fix took time_zone only as far as UTC — a non-zero offset was refused rather than stored and silently not honoured, which was the right refusal to make and not a place to stop. Since 1.11.3 a numeric offset within ±14:00 is accepted and applied: the local now-family reads in the session zone, the UTC_* forms stay in UTC, and FROM_UNIXTIME and UNIX_TIMESTAMP interpret their value there. A named zone like Europe/Oslo is still refused, because it needs a zone table carrying DST rules that a fixed offset cannot express — and answering with one anyway is how a timestamp comes back an hour wrong twice a year.

Vector native

VECTOR(n) columns with exact and HNSW approximate nearest-neighbour search — semantic search lives in the database.

Hybrid search

HYBRID(text, 'query', vec, vector) fuses full-text and vector rankings with Reciprocal Rank Fusion, honouring your WHERE filter — the RAG retrieval stack in one query, no Elasticsearch or reranker.

In-SQL embeddings

ai_embed('text') calls an OpenAI-compatible endpoint (cloud or a local Ollama/vLLM server) and returns the vector, so query vectors and stored values are generated directly in SQL. Since 1.11.0 a CREATE EMBEDDING INDEX ... USING MODEL keeps a vector column in step with a text column on its own, so the application stops orchestrating re-embedding. What needs embedding is derived from the data — a hash of the model and the text against the one recorded when the vector was written, not a write hook — so a bulk load, a restore, binlog replay and a replication apply are all covered rather than only the INSERT path. Write-back runs in a serializable transaction, so an UPDATE landing while the provider is being called is never overwritten; failures back off and dead-letter after five attempts, which SHOW EMBEDDING INDEXES reports as Retrying or Failed.

Parallel OLAP

Large aggregations run through a parallel, streaming engine — analytical queries without a separate warehouse. Since 1.11.3 EXPLAIN says which path a query took: a grouped query used to answer type=ALL with an empty Extra, identical for the vectorised path and the one that spills to disk, so the server's defining feature was invisible from the client and nobody could see when a rewrite had pushed a query off the fast path. Extra now names it — columnar scalar, columnar group, parallel streaming, partitioned and spilling — and since 1.12.0 adds primary-key range, zone maps or columnar cache only when that is what will run; before, every columnar GROUP BY claimed zone maps, even with them turned off. 1.12.0 also sends a single aggregate down the columnar path and lets aggregation use up to eight cores, and an aggregate or GROUP BY over a primary-key range now reads only that range: SUM over half of 20M rows went from 1761 to 281 ms, and GROUP BY with id = 777 from 200 to 0.2 ms.

Full SQL surface

Joins, correlated subqueries, CTEs with WITH RECURSIVE, window functions, set operations and a large function catalog.

Serializable transactions

Snapshot and serializable isolation, so concurrent writers stay correct under contention.

Introspection

SHOW statements and INFORMATION_SCHEMA so existing tooling can browse the catalog out of the box.

Replication & HA

Built-in replication for read scaling and high-availability deployments, with Raft-style leader election for automatic failover. All inter-node traffic — the replication stream and the consensus control plane — is TLS-encrypted with peer-certificate verification when the cluster TLS variables are set, so a node that cannot verify a peer does not form a cluster with it. From 1.9.5 that fails closed. Through 1.9.4 setting only one of the two variables, or pointing them at a certificate that would not load, logged a warning and carried on unencrypted — so the operator most likely to be caught was the one who had configured TLS and mistyped a path. The control plane also refuses to bind to a non-loopback address without authentication now, rather than trusting the network. The replication endpoint itself was a wider gap, closed in 1.9.9: it hands a full copy of the database to every peer that connects, and until then it required no authentication on loopback at all — and the replica subcommand had no authentication flags in the first place, so it always started open. Both now refuse to start without ELYRASQL_CLUSTER_SECRET, and a replica verifies its primary before applying anything it sends, because a replica that cannot tell who is feeding it will serve whatever it is given.

Reviewed by someone who is not us

The last two releases came in as reviewed contribution stacks from an outside engineer, @HelgeSverre, who ran commercial Laravel codebases against isolated instances and reduced every failure to a generic MySQL reproduction. Seven fixes in 1.9.0 alone — each verified against MySQL 8.4 on identical data before merging, and in several cases by comparing the table contents afterwards rather than the statement's return code, because the failure mode was a statement that looked like it worked. None of them came from a bug report; they came from someone reading carefully with a reference implementation open.

Every one of them found by asking MySQL

The 1.8.0 corrections came from differential testing, not from reports — and three were silent, the kind that survives a suite written by the people who wrote the engine. CREATE TABLE ... AS SELECT was cut at the first parenthesis, so an aggregate lost half its query (materialized views run through CTAS, so those never worked either). UNSIGNED was enforced on BIGINT but not on INT, so Laravel got the constraint on foreignId() columns and not on unsignedInteger() ones. And SHOW CREATE TABLE omitted CHECK and FOREIGN KEY, so a dumped schema quietly lost constraints that were still being enforced. The harness is still finding them, and still by asking MySQL. 1.11.3: NOW() read the clock afresh on every call, so SELECT NOW() = NOW() was 0 and an ORM writing created_at and updated_at in one INSERT stored two different timestamps — the family is resolved to one instant per statement now, as MySQL does, with SYSDATE() left live because MySQL leaves it. And SET @var inside a stored procedure landed in the procedure's local scope and was discarded when CALL returned, which the harness had never covered because it had no stored-procedure section. It has one now.

Laravel migrations run unmodified

Four commercial Laravel codebases were run against isolated ElyraSQL instances — the largest with 469 migration files — and every failure reduced to a generic MySQL reproduction. Two full application suites now pass: 278 tests / 764 assertions and 169 tests / 762 assertions. Almost none of the fixes were engine bugs; they were places where ElyraSQL answered correctly but differently — a column label, a coercion under a session SQL mode, a metadata field — and each of those differences would have become a driver-specific workaround in somebody's application. 103 new end-to-end tests run through the MySQL wire protocol to keep them closed. Since 1.12.1 DB_DATABASE can be any name: ElyraSQL accepts it as an alias for its one database and reports it as the schema, so Schema::hasTable() and SHOW introspection find what they look for, but two applications pointed at different names share one set of tables, so each application gets its own ElyraSQL instance.

Native on Apple Silicon

Tagged releases now attach a macos-aarch64 archive alongside the Linux ones, built and tested on an arm64 macOS runner with the minimum supported Rust toolchain — not cross-compiled and hoped for. The build targets macOS 11, and CI verifies the Mach-O architecture, minimum OS and signature, then runs the binary again after extracting the archive. Build metadata tells the truth too: @@version_compile_machine no longer claims an Apple Silicon build is Linux x86_64.

Checked against real MySQL, at every threshold

Every result is diffed row for row against an actual MySQL 8.4, because the failure that matters is not a crash — it is a query that quietly returns the wrong number. A 203-case differential battery was not enough: three wrong-result bugs still shipped, and all of them hid below the row counts the tests used. So the suite now replays its battery at sizes bracketing every internal threshold — 127/128/129, 255/256/257, 2047/2048/2049, 4095/4097, 8193 — where byte boundaries, join-strategy switches and spill partitions change behaviour. That is what caught the 1.4.14 aggregate bugs — and in 1.6.0 a join ON spanning both tables that was hashed as if it were an equi key, silently returning twice the rows MySQL does. It gates every push. A second harness that compares full table digests after a dump and reload was less lucky: added in 1.9.1, never wired into CI, and quietly broken from that release until 1.9.4 noticed. It runs nightly now, against five schema models — a tool nobody runs is a tool that has stopped working. Row-for-row was still not the whole answer, and 1.9.6 showed how: a boolean used as a number came back DOUBLE where MySQL sends BIGINT and every value matched, so every case passed — and nothing was looking at affected-row counts at all, which is how five upsert shapes reported the wrong number across two code paths. From 1.9.7 the differential compares column types and affected rows too, folding the distinctions no client can observe (integer widths, the string/blob family) so the ones that matter stay visible: comparing types exactly flagged 88 of 210 cases, and folding brings that to 19 tracked with their reason. A check that cries wolf gets skimmed, and then the real finding beside it does too. Verified by running it against the pre-fix binary, where it reports precisely the twelve divergences it was built to catch. 1.12.1 closes three more places where ElyraSQL was close to MySQL and not equal to it. START TRANSACTION READ ONLY was accepted and not enforced, so an INSERT inside one ran and only a later ROLLBACK undid it; every data write is now refused before it runs, with MySQL's error 1792 (DDL in a read-only transaction commits it first and runs, as in MySQL, since 1.12.3), and the forms around it work as in MySQL, checked statement by statement against MySQL 8.4. SELECT ... INTO @var assigns the variable instead of returning the row, and DROP DATABASE of a name that holds nothing is MySQL's 1008, not not supported. Three fixes that change what existing code can see come with an upgrade note. 1.12.2 adds a fourth: a SELECT in a procedure body ran but its rows were dropped, so CALL answered with only an OK. Every SELECT, in loops, handlers and nested CALLs too, now comes back as its own result set, followed by the CALL's OK, as MySQL sends them, over the text and the binary protocol. The server now offers CLIENT_MULTI_RESULTS, which clients such as mysql_async and sqlx only use when offered, and a client without it gets MySQL's 1312. A client that called a procedure and read only an OK will find result sets first, and has to read them or move on to the next result. 1.12.3 adds three more, each checked against MySQL 8.4. \_ and \% in a LIKE pattern match literally: the backslash was dropped when the string was read, so 'axb' LIKE 'a\_b' was true, and a search that escapes its input with Laravel's addcslashes($term, '%_') matched rows it should not have. DDL commits the open transaction first, as in MySQL: CREATE, ALTER, DROP, RENAME, TRUNCATE, account statements, LOCK TABLES, ANALYZE TABLE and a new BEGIN used to run inside the open transaction, so a ROLLBACK after INSERT followed by CREATE TABLE undid the INSERT as well, and a second BEGIN silently discarded its writes, where MySQL commits them; code that relied on a ROLLBACK reaching back past a DDL statement will no longer get that. And SHOW TABLES applies its LIKE and WHERE and lists views: SHOW TABLES LIKE 'nope' used to list every table, so a tool that checked for a table that way always found it.

One query cannot stall the rest

A long synchronous stretch — a join product, a sort, aggregation over materialised rows — used to monopolise a runtime worker, and enough of them stopped the server accepting connections at all. Heavy stretches now hand the worker back and the streaming joins yield, with no configuration: 32 runaway queries on 16 cores still leave a new connection answered in under 0.1 s. Set ELYRASQL_QUERY_TIMEOUT_MS and the deadline is enforced inside the row loops, so a runaway actually stops burning CPU.

Bounded, the way MySQL is

One authenticated client should not be able to take the server down. Connections, prepared statements, streamed parameters and materialising joins are capped with MySQL's own variables, defaults and error codes — max_connections at 151 answering surplus connections with 1040 rather than a bare reset, max_prepared_stmt_count at 16382 with 1461, max_allowed_packet at 64 MiB with 1153 — and, as in MySQL, one connection slot is reserved for an Admin account so an operator can still get in and KILL sessions on a saturated server. A materialising join is bounded per join and across all of them at once (ELYRASQL_JOIN_MAX_ROWS, ELYRASQL_JOIN_MAX_ROWS_TOTAL), because memory scales with concurrency: eight concurrent cross joins went from 97 GB and a killed process to a 5.4 GB plateau with an error that names the limit.

Sorts Nordic text the way MySQL does

The default collation is utf8mb4_0900_ai_ci, matching MySQL 8: case- and accent-insensitive, so ‘café’ = ‘cafe’, ‘Straße’ = ‘Strasse’, ‘æ’ = ‘ae’, and Ærlig sorts among the A’s instead of after zz. This was not a cosmetic sort-order difference — WHERE s > ‘cat’ returned a different set of rows than MySQL, which is a wrong answer for Nordic and other European text. The folding table is derived from MySQL's own WEIGHT_STRING output rather than written by hand, so it cannot disagree with the collation it implements.

Rows cost what the query reads

A row is one encoded blob, so materialising a column nobody selected still pays for the allocation — and row-oriented paths decoded every column of every row, meaning cost scaled with how wide your table is rather than with what you asked for. Scans now decode only the referenced columns, and ORDER BY … LIMIT k asks whether a row would survive the top-N before building it at all. On 200k rows: ORDER BY … LIMIT 100 over 12 columns 95 → 31 ms, a 1:1 join COUNT(*) 492 → 150 ms, and a join emitting 40M rows 33.3 s → 1.13 s. An unbounded sort and a plain COUNT(*) scan did not move — the controls matter as much as the gains, because nothing was traded away.

Non-equi joins answer instead of erroring

ON a.id < b.id or a BETWEEN band join had no equality to hash, so the whole product was materialised until it hit the memory ceiling and the query was refused. Those joins now stream: memory stays flat — a 20k-row three-way inequality join leaves the server at its 34 MB idle footprint. Be aware the work is still quadratic and the row ceilings no longer bound it, so bound the time with ELYRASQL_QUERY_TIMEOUT_MS instead. When the ON mixes an equality with other conditions, the equality is still hashed and the rest applied as a residual.

A planner that counts the cost

Using an index is not automatically cheaper: a secondary-index range fetches every matching row by key, so a wide range was slower than reading the table — COUNT(*) WHERE amt > 0 took 124 ms, while the same rows written as a non-indexable filter took 2.7 ms. A range matching more than ELYRASQL_INDEX_RANGE_MAX_FRACTION of the table now falls back to a scan, decided after walking the keys but before fetching a single row, so a misjudged range costs only a key walk. And col IN (…) finally uses the index, or compiles to a hash-set test when a scan is right. On 200k rows: amt > 0 124 → 16.5 ms, IN (500 values) 102 → 5.7 ms.

OLAP-fast

Vectorised columnar aggregation and parallel clustered scans — on native Linux over 1M rows, the fastest of ElyraSQL, PostgreSQL 17 and MySQL 8.4 on every OLAP aggregation, 2–5× ahead of MySQL. Written in Rust, no GC pauses.

Get SQL Server

MySQL-compatible,
one file, in Rust.

Free and open source. Pair it with the SQL Client, or explore the whole SQL family.