| Name | Modified | Size | Downloads / Week |
|---|---|---|---|
| Parent folder | |||
| README.md | 2026-10-21 | 24.9 kB | |
| v2.2.0 source code.tar.gz | 2026-10-21 | 119.6 MB | |
| v2.2.0 source code.zip | 2026-10-21 | 121.3 MB | |
| Totals: 3 Items | 240.9 MB | 1 | |
Hey folks - XTDB 2.2.0 is here! π
It's been a long road - so if you've been following along, thank you; if you haven't, here's the whole story in one place.
2.2 is about three things:
- External sources - XTDB can now source its data from systems you already run, so you can run XT alongside your existing databases/topics rather than migrating to it.
- Arrow ADBC & Flight SQL - we've always stored everything as Arrow; now you can get it out as Arrow, all the way into your application's memory.
- A single writer per database - each database now elects a leader, which takes load off the rest of the cluster and makes single-put transactions an order of magnitude faster.
There are a handful of breaking changes coming from 2.1, and a rolling upgrade has to go via 2.1.1 - please do have a read of Upgrading from 2.1 before you roll. (For anyone on a 2.2 pre-release: rc2 -> 2.2.0 is a straight upgrade; from anything earlier, see the rc2 upgrade notes.)
External sources - bring your data to XT, without migrating to it
Until now, everything in XTDB arrived through an XTDB node. In 2.2, XTDB can source its transactions from systems you already run.
This means that you don't have to commit (π) to XTDB as your primary store to get its value. Keep your existing database, run XT alongside it as a secondary, and get bitemporal audit and time-travel reporting over data you're already producing. Or hang XT off your event-sourcing infrastructure as a time-oriented projection of your event streams.
Two sources ship in 2.2:
- PostgreSQL - XTDB connects to your existing Postgres cluster as a logical-replication client, and mirrors its changes in as bitemporal history (setup guide).
- Kafka Connect - interoperable with your existing Kafka Connect transforms and predicates, so XT sits naturally in a Kafka Connect topology (setup guide).
- (and we did also spike kdb+ for fun one afternoon, but that's very much at proof-of-concept level for now)
Setting one up is a remote in your node config:
:::yaml
remotes:
pg_remote: !Postgres
hostname: my.pg.host
port: 5432
database: my_upstream_db
username: !Env PGUSER
password: !Env PGPASSWORD
and then an ATTACH DATABASE:
:::sql
ATTACH DATABASE pg_test_db WITH $$
log: !Local
path: 'pg_test_db/log'
storage: !Local
path: 'pg_test_db/storage'
externalSource: !Postgres
remote: pg_remote
publicationName: xtdb
slotName: xtdb
indexer: !DirectMirror {}
$$
If you're running one in production, the Postgres source's setup guide and reference cover what you'll want to know about the upstream - PostgreSQL 17 or later, sizing your WAL retention, and surviving a failover.
Under both sources sits the same pluggable source SPI we used to build them (in xtdb.api.tx) - so you can write your own, against whatever upstream you like.
It's a supported part of the public API from 2.2.0; if you build one, we'd love to see it - please do get in touch.
And if you'd rather keep ingestion separate from your query nodes, there's a first-class ingest-only node: xtdb.main ingest -f config.yaml (or the Docker ingest command) runs sources, indexers and logs from YAML, independently of your query nodes (docs).
See the external sources overview for the full picture.
If you've got existing Postgres tables or Kafka event streams, this is the bit of 2.2 I'd most like you to kick the tyres on - please do let us know how you get on.
Arrow ADBC & Flight SQL
We've been excitedly following Arrow ADBC and Flight SQL for a while now - in 2.2, XTDB supports both: ADBC in-process, and a Flight SQL server for remote access.
For those who haven't come across it: ADBC ('Arrow Database Connectivity') is a database client API in the spirit of JDBC and ODBC, but built around Apache Arrow's columnar format throughout. Flight SQL is the Arrow project's SQL wire protocol, which ADBC clients use to talk to a remote server.
This is a particularly natural fit for XT, because we already store everything internally as Arrow. Queries over the Postgres wire protocol pay a hidden tax: we translate out of Arrow into pgwire's row-oriented types, only for your client to possibly translate it back into something columnar - a pandas or Polars DataFrame, a DuckDB table, an Arrow Dataset. With ADBC and Flight SQL, that round trip goes away: results stay as Arrow from XTDB's storage all the way into your application's memory.
From Python, over the wire, via the Flight SQL driver - bulk-ingesting an Arrow table in one round trip, and getting the results back as Arrow:
:::python
import adbc_driver_flightsql.dbapi as flight_sql
import pyarrow as pa
with flight_sql.connect("grpc://localhost:9832") as conn:
with conn.cursor() as cur:
users = pa.table({
"_id": ["alice", "bob", "carol"],
"name": ["Alice", "Bob", "Carol"],
"age": [30, 25, 28],
})
cur.adbc_ingest("users", users, mode="create_append")
cur.execute("SELECT _id, name FROM users WHERE age > ?", parameters=[26])
print(cur.fetch_arrow_table().to_pydict())
From Kotlin/Java, in-process - node.connect() returns an ADBC connection, so there's no network hop and no copy:
:::kotlin
Xtdb.openNode().use { node ->
node.connect().use { conn ->
val stmt = conn.createStatement()
stmt.setSqlQuery("SELECT _id, name FROM users WHERE age > 26")
stmt.executeQuery().use { result ->
// result.reader is an org.apache.arrow.vector.ipc.ArrowReader
}
}
}
And from Clojure, xtdb.api now accepts an ADBC connection as well as a JDBC one - in-process, we've seen up to 3x over going through pgwire.
If you've been waiting to point an Arrow-native client at XTDB - PyArrow's Flight SQL client, Python's adbc-driver-flightsql, the JDBC Flight SQL bridge - this is the release where it works end-to-end.
The full supported surface is in the ADBC reference.
A shout-out, too, to the folks at Columnar - founded by core Apache Arrow / ADBC developers - whose CLI dbc takes the friction out of installing ADBC drivers.
We're aiming to land an XTDB driver in dbc in due course - keep your eyes peeled.
A single writer per database
Since the very first release of XTDB 1, every node has read the whole transaction log and resolved every transaction itself. In 2.2, each database elects a leader instead: the leader resolves each transaction once, and writes the outcome to a 'replica log' that the rest of the cluster follows.
What that means for you:
- Less work for the rest of the cluster - followers apply the leader's results, rather than each re-running every transaction themselves.
- Read-only secondary nodes - nodes that never contend for leadership, they just read the leader's results. This opens the door to XTDB as a federated database: query across other XTDB deployments in your organisation, just by opening read-only access to their log and object store.
- Single-put transactions are an order of magnitude faster. If you're ingesting one transaction per message - off an existing Kafka topic, say - each transaction used to wait out a network round trip; now the leader pipelines them:
| 1M documents, Kafka replica log | batch size 1 | batch size 1000 |
|---|---|---|
| awaiting each append | 246.1 s | 2.68 s |
| pipelined (2.2.0) | 22.8 s | 2.70 s |
A design partner's production deployment, ingesting 1M of its own (larger) documents off Kafka, went from 264 s to 61 s.
- Kafka no longer elects our leaders for us. XT runs its own (Raft-inspired) election in the replica log, so it no longer needs consumer groups or transactional producers - which means Azure Event Hubs' standard tier now works, and it's the first step towards Kafka not being a (pretty much) mandatory dependency for highly-available deployments.
If you're curious how it works - and how we got there (via a couple of pre-releases I wasn't happy with π ) I've written it up on the blog: Following the leader: leadership election in XTDB 2.2.
Postgres-ecosystem compatibility
One of the main reasons we went with SQL and Postgres wire-compatibility in XT 2.x was to make the vast Postgres ecosystem available to XTDB users - so a fair proportion of 2.2 has gone on supporting the SQL that its tools expect to find.
We've been led largely by what our clients actually use: Metabase and Grafana both now connect to XTDB through their standard Postgres connectors, and on the .NET side, Npgsql and Entity Framework Core (LINQ queries, and dotnet ef dbcontext scaffold) now work too.
The bits that fell out of that, which aren't tool-specific:
- Functions:
LEAD,LAG,PERCENTILE_CONT/PERCENTILE_DISC(medians, for one),MIN/MAXover strings,ILIKE,array_agg(x ORDER BY y),string_to_array,array_lengthand friends,IS [NOT] DISTINCT FROM, andRANGEalongsideGENERATE_SERIES. - Aggregate
FILTER, so you can count two different things over the same groups without reaching for a subquery:
sql
SELECT dept,
COUNT(*) AS headcount,
COUNT(*) FILTER (WHERE status = 'active') AS active
FROM employees
GROUP BY dept
- Expressions where you'd expect them to work:
GROUP BYtakes expressions and select-list aliases (GROUP BY DATE_TRUNC('day', created_at)), subqueries can go inORDER BY, and function calls can go inFROM. - Postgres's spellings: casts take Postgres's type names (
x::int4,float8,bool,int8[]), Postgres's non-reserved keywords (date,year,row, ...) work as table and column names, andWHERE created_at > '2026-01-01'does the obvious thing. - Session and transaction surface:
SET TRANSACTION,COMMIT [SYNC | ASYNC],DISCARD ALL, andCREATE TABLEwith columns. - Catalog introspection: much more of
pg_catalogandinformation_schemais modelled, so the probes these tools fire on connect plan and return sensible answers rather than erroring. - Identifying XT:
version()now returnsPostgreSQL 16 (XTDB 2.2.0), andcurrent_setting('xtdb.version')gives you the version on its own - if you've been sniffing for XT by probing incidental behaviour, this is the intended way.
Operations & observability
With XTDB now in production across a number of client sites, we've had a lot of valuable feedback (thank you!) on what actually matters when you're running it:
- Garbage collection: XT's primary index is a log-structured merge tree on object storage, which periodically compacts its files; 2.2 adds a background process that removes the files that compaction has superseded, keeping storage costs in check.
- Blocks are now flushed every 15 minutes on a quiet database, rather than every four hours (breaking, in that it's a new default). That interval turns out to bound four separate things: how much recent data lives only in the log, how long a starting node spends replaying the tail, how stale a read-only secondary can be, and how much WAL an external source's upstream has to retain. Busy databases cut blocks on row count long before the timer fires, so they're unaffected.
- Tracing: OpenTelemetry tracing now covers transactions as well as queries, and you can enable each independently, so you don't pay for the noise of one while you're investigating the other.
- Metrics: plenty more for your Prometheus dashboards - per-database lag, leader/follower status, end-to-end pgwire latency, block-upload times, allocator memory, GC, and the external sources.
EXPLAIN ANALYZE: scans now report the rows, pages and files they pruned versus used, and every operator reports aself_timealongside itstotal_time- so you can see at a glance both which work the planner elided and where the time actually went.- Cleaning up after clients: a pgwire client that disconnects mid-query now cancels it, and a cancel reaches every operator in the plan rather than only being checked at the root.
- Fewer ways to wedge a node: object-store I/O is now bounded, so one stalled request no longer exhausts the shared pool; a replica-log message too large for Kafka goes to object storage rather than stopping the database; and a node whose log is behind its storage refuses to start (pointing you at the runbook) rather than acknowledging transactions it would then discard.
Performance
We've spent a fair proportion of the 2.2 line with our heads in the profiler. As well as single-put transactions (see above):
WHERE _id IN (...)on non-UUID_ids is ~10-60x faster, depending on the size of the list - the index lookup no longer degenerates into a full table scan.- The ingest path: a transaction now writes straight into the live index rather than being copied in on commit (15.2k -> 27.8k single-put transactions/s); a repeated DML statement plans once rather than on every execute (861 Β΅s -> 674 Β΅s per single-row
INSERTover pgwire); and a transaction's Arrow data is serialised with one copy rather than four or five. - Sorts with a
LIMITkeep only their top k rows, rather than sorting everything and throwing most of it away. - Join conditions that only read one side are now evaluated in that side's scan, rather than once per candidate pair.
- Correlated
EXISTS (SELECT 1 ...)- i.e. how you'd idiomatically write it - now decorrelates into a semi-join. - Scans no longer copy every freshly-written page on load - on a TPC-H SF1 scan, that copy was 3s of 4.8s.
Upgrading from 2.1
2.2 writes transactions to the log in a new format, which 2.1.0 can't read - so how you upgrade depends on whether you can stop the cluster:
- Stop-start (all nodes down, then all nodes up): directly from 2.1.0.
- Rolling / blue-green: via 2.1.1 - 2.1.0 -> 2.1.1 -> 2.2.0, each step rolling. 2.1.1 is 2.1.0 plus the ability to read 2.2's transactions, so it can share a log with 2.2 nodes while you roll (#6248). Going straight from 2.1.0, the first transaction a 2.2 node accepts stops the remaining 2.1.0 nodes ingesting. Do keep the window where 2.1.1 and 2.2 nodes are running side by side short - they each write blocks, so it's not somewhere to linger.
Before you roll - these will stop a node starting, or change how it runs:
- Authentication:
CREATE USER/ALTER USERand the!UserTableauthenticator have gone. Move statically-configured users to!UserList, setXTDB_PASSWORDfor the local-dev root user, or - our recommendation for production - use OIDC (docs). - Logging: we've moved from Logback to Log4j2, defaulting to structured JSON - if you had a custom
logback.xml, you'll need to port it (docs). - Docker: the jar is now always
xtdb.jarand the default config alwaysconfig.yaml, so update any custom Dockerfiles, init scripts or volume mounts pointing at the old paths. The images also now ship JDK 25, so any flags you pass throughJDK_JAVA_OPTIONSneed to be valid on 25. - In-process on JDK 25: add
-Dio.netty.noUnsafe=falsealongside the existing--add-opens- Arrow's allocator fails to initialise without it. (JDK 21 is still the minimum, and nothing changes there.) - Kafka: each database now has two topics - its existing source topic, and a replica topic, defaulting to
${topic}-replica. XT will auto-create it ifautoCreateTopicis on, but auto-created topics are unreplicated, so for production you'll want to create it yourself; if you're using ACLs, XT needsDescribe/Read/Writeon both (docs).
SQL and behaviour changes - worth a check against your queries:
- Unknown tables and columns now error, rather than silently returning nothing - catching a typo'd table name is rather more useful than an empty result.
CREATE TABLE(with or without columns) declares a table before you've written to it. GENERATE_SERIESis end-inclusive, matching Postgres - if you relied on the old exclusive end, switch toRANGE.USERis now a reserved word (a synonym forCURRENT_USER, as in the SQL spec) - a table or column calledusernow needs quoting as"user".FROM foo(x)is a function call; a column list on a table needs an alias, as in standard SQL -FROM foo AS f(x).- Operators bind in Postgres's precedence order - XT had drifted, mostly into obviously-wrong readings (
a = b IS TRUEread asa = (b IS TRUE)), but a query that happened to rely on one will now bind the other way. - A transaction's snapshot and
current_timestampare fixed at its first query, not atBEGIN, as Postgres does - so a write by another connection between yourBEGINand your first query is now visible. If you were relying onBEGINto pin them, pin them explicitly withSNAPSHOT_TOKEN/CLOCK_TIME. SETinside a transaction belongs to it -ROLLBACKdiscards it, and one that can't be honoured (in a read-write transaction with buffered writes, say) is rejected.- An explicit
SYSTEM_TIMEhas to be later than the latest transaction's - if you're backfilling, give each transaction its own. UPDATEskips writes that would produce an identical document version, so an idempotent updater no longer adds history.information_schema.columns.data_typeholds Postgres's type names (bigint,timestamp with time zone); XT's own rendering has moved to a newxt_typecolumn.- JSON output: decimals come back as numbers rather than strings, and error details over pgwire are plain JSON (
{"category": ..., "code": ..., "message": ..., "data": ...}) rather than JSON-LD. - Blocks flush every 15 minutes on a quiet database (see above) - expect more, smaller blocks, and correspondingly more compaction work. An explicit
flushDurationis honoured as before.
Deprecated - still working in 2.2, but on their way out:
- Inline storage credentials in
ATTACH DATABASE(S3credentials, AzurestorageAccountKey) end up on the source log, and from there in your object store. Move them into node-local!S3/!AzureBlobremotes and reference those withremote:instead - only the alias is persisted. (Ambient AWS/Azure/GCP credentials are unaffected.) logClustersis nowremotes- the old name is still honoured, so rename when convenient.
Rolling back
You can roll back to 2.1.1 (not 2.1.0, which can't read 2.2's transactions):
- Detach any databases 2.1 doesn't have a concept of - read-only secondaries and external-source databases. 2.1 would read them as ordinary read-write databases, and a read-only attachment to another deployment would start writing blocks into its storage.
- Revert the config changes above - authentication, logging, Docker paths,
remotes:->logClusters:- and remove any 2.2-only config (flightSql,remote:references in storage config). - Roll back to 2.1.1.
- Before rolling forward to 2.2 again, delete each database's replica topic (
${topic}-replica, unless you've named it) - otherwise the 2.2 nodes would replay the old replica log on top of the blocks 2.1.1 has since written.
If you're unsure about any of this - particularly if you're running external sources or read-only secondaries - please do get in touch before you roll, rather than after π
Elsewhere
- #5543 -
FOR VALID_TIME ONLY FROM x TO yclamps the_valid_from/_valid_toyou get back to that window - which also lets the scan skip pages it would otherwise read only to clamp away.UPDATE,DELETEandPATCHalways mean 'only', so they get the same speed-up for free. - #5397, #5402 -
xt.source_log_msgs,xt.replica_log_msgs, and block/table-block admin tables (xt.block_filesand friends), so you can see what's on the log and in storage from SQL, rather than reaching for the CLI tools. - #5425 -
XTDB_SKIP_DBSstarts a node with the named secondary databases left dormant - for when a secondary's topic has gone, and would otherwise stop the whole node starting. - #5860 - from Clojure,
xtdb.apialso works over a remote Flight SQL connection (xtdb.flight-sql/open-conn), not just in-process. - #5821 - query and transaction metrics are recorded for in-process and Flight SQL callers too, not only over pgwire.
- #5909 -
/healthz/startedno longer waits on a non-critical database catching up. - #5317 - pgwire runs its connections on virtual threads, which stops glibc's malloc arenas growing with every new connection thread - previously a slow climb towards a container OOM-kill under query load.
- #5218, #5248, #6140 - S3: uploads no longer copy buffers onto the heap (an OOM, then a stalled node), and a block with thousands of tables no longer exhausts the client's connection pool.
- #5778 - Kafka retention expiring past a quiet database's last block no longer stops that node ingesting on restart.
- Ingestion no longer stops on a
PATCHwith a nested null (#4990), aPATCHover lists of structs containing nulls (#5268), or a column holding only empty lists (#5948). - Wrong results, now right: more than eight
MATERIALIZEDCTEs ran in the wrong order (#5109),POSITIONover aSUBSTRINGignored the substring (#5168), ordered-set aggregates dropped trailing groups (#5957),ORDER BY ... DESCputNULLS FIRST/LASTon the wrong side (#6172), and concurrent Arrow result streams could corrupt each other (#6231). - #6150 - the AWS deployment is now Terraform and Helm only; the CloudFormation templates have gone.
As always, the milestone has the full list - all 216 of them.
2.2 has been one of the longest release lines we've run, and we couldn't have got it here without the people running the pre-releases in anger. A big thanks to everyone raising issues, sending reproductions and benchmarks, and kicking the tyres - and especially to our design partners and clients: an awful lot of what's above started life as an issue raised from a production deployment, and it's massively appreciated π
If you've any questions or thoughts, please do get in touch - we'd love to hear from you!
Cheers!
James & the XT Team