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.
# 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 SQL Server
Self-contained tarballs for Linux and Apple Silicon macOS, or build from source.
Linux · x86_64
elyrasql-1.9.9-linux-x86_64.tar.gz
6.1 MB
Linux · ARM64
elyrasql-1.9.9-linux-aarch64.tar.gz
5.6 MB
macOS · ARM64
elyrasql-1.9.9-macos-aarch64.tar.gz
5.2 MB
Running replication on 1.9.8 or earlier? Upgrade now, and treat the data as exposed.
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.
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.
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, Rust (sqlx) and Node.
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.
Parallel OLAP
Large aggregations run through a parallel, streaming engine — analytical queries without a separate warehouse.
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.
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.
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.
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.
MySQL-compatible,
one file, in Rust.
Free and open source. Pair it with the SQL Client, or explore the whole SQL family.