An Elasticsearch alternative for search and analytics in SQL
SereneDB is an Apache 2.0 database that ranks text with BM25 and answers in SQL over the PostgreSQL protocol. Where Elasticsearch searches the documents you ingest into its indices, SereneDB also indexes Parquet files, Iceberg tables and PostgreSQL or ClickHouse tables where they already are, and one query searches, joins and aggregates them.
median hot query time · the 88 queries both finish
Search, joins and aggregations in one SQL engine
Teams that switch from Elasticsearch keep BM25 relevance and gain the rest of SQL. In SereneDB a search is one clause of a query, so the same statement joins what it finds to other tables and aggregates it.
> Search and analytics in one statement
A BM25 match (@@) sits in the same SQL statement as GROUP BY, joins, window functions and CTEs, so a search result is aggregated or joined without a second query.
> Index data where it already lives
Build an inverted index over Parquet, CSV or JSON files on S3 or disk, Iceberg tables, or PostgreSQL and ClickHouse tables. The rows stay at the source; OpenTelemetry logs can also arrive over OTLP/HTTP.
> Apache 2.0
SereneDB is licensed under Apache 2.0 and installs as one binary with Docker, a .deb package or a tarball. There is no JVM heap to size.
> Keep your clients while you port
An Elasticsearch-compatible REST API answers _bulk and _search with match, term, range and bool queries and terms, date histogram and metric aggregations. Every index it creates is also a SQL table.
SereneDB vs Elasticsearch
Both rank text with BM25. They differ in where the data has to be, how you query it, and what runs around the engine.
| Capability | SereneDB | Elasticsearch |
|---|---|---|
| Data location | Yes: Indexes files and tables in place: Parquet, CSV and JSON on S3 or disk, Iceberg, attached PostgreSQL and ClickHouse | No: Searches the indices you ingest into; ES|QL data federation over S3 files is experimental in 9.5 |
| Query language | Yes: PostgreSQL-dialect SQL over the PostgreSQL wire protocol | Yes: Query DSL, ES|QL and SQL; its SQL reads one index at a time |
| Joins | Yes: Any SQL join across tables, indexes, files and attached databases | Partly: ES|QL LOOKUP JOIN onto a lookup-mode index; no joins between regular indices |
| Full-text search | Yes: IResearch: BM25 and other scorers, phrase, fuzzy and regex queries, highlighting, stemming in 34 languages | Yes: Lucene: BM25 by default, language analyzers, fuzzy queries, highlighting |
| Vector and hybrid search | Yes: HNSW and IVF in the same inverted index; hybrid ranking written in SQL | Yes: HNSW with BBQ quantization by default from 384 dimensions, filtered kNN, RRF and linear retrievers |
| Analytics | Yes: GROUP BY, exact percentiles, window functions and CTEs next to the search predicate | Partly: Aggregations, capped at 65,536 buckets by default; percentiles are approximate; ES|QL documents no window functions |
| Updates and transactions | Yes: UPDATE, DELETE and multi-statement ACID transactions; the search index catches up within about a second | Partly: Per-document updates and update by query without rollback; no multi-document transactions |
| Schema changes | Yes: ALTER TABLE adds, renames and drops columns of a regular table; ADD COLUMN leaves indexes as they are | Partly: New fields map dynamically; changing a field's type needs a new index and a reindex |
| Dashboards and integrations | Partly: Grafana through its PostgreSQL data source, psql, DBeaver, DataGrip, SereneUI | Yes: Kibana, and integrations through Elastic Agent, Beats and Logstash |
| License | Yes: Apache 2.0 | Partly: AGPLv3, SSPL or Elastic License 2.0 for the source, ELv2 binaries; some features need a paid subscription |
✓ supported · ~ supported with limits · ✕ not supported. Elasticsearch facts from Elastic's documentation for 9.5, SereneDB facts from its documentation and runs on 26.10.7. Last reviewed October 30, 2026.
Query DSL in, SQL out
Each tab asks one question in Elasticsearch and in SereneDB. The SQL ran on SereneDB 26.10.7 on October 30, 2026; the Elasticsearch requests follow Elastic's 9.5 documentation.
Log search
GET logs/_search
{
"size": 50,
"sort": [{ "@timestamp": { "order": "desc" } }],
"query": {
"bool": {
"must": [{ "match": { "message": "timeout" } }],
"filter": [
{ "term": { "service": "checkout" } },
{ "range": { "@timestamp": { "gte": "now-15m" } } }
]
}
}
}SELECT ts, service, message
FROM logs
WHERE message @@ 'timeout'
AND service = 'checkout'
AND ts > now() - INTERVAL '15 minutes'
ORDER BY ts DESC
LIMIT 50;logs is a search table (WITH (storage = 'search')) with an inverted index on message. Filters, sorting and the limit are plain SQL around the @@ match.
Aggregation
GET logs/_search
{
"size": 0,
"query": {
"bool": {
"must": [{ "match": { "message": "timeout" } }],
"filter": [{ "range": { "@timestamp": { "gte": "now-1h" } } }]
}
},
"aggs": {
"per_15m": {
"date_histogram": { "field": "@timestamp", "fixed_interval": "15m" },
"aggs": {
"p95_ms": {
"percentiles": { "field": "latency_ms", "percents": [95] }
}
}
}
}
}SELECT time_bucket(INTERVAL '15 minutes', ts) AS bucket,
count(*) AS errors,
round(quantile_cont(latency_ms, 0.95)) AS p95_ms
FROM logs
WHERE message @@ 'timeout'
AND ts >= now() - INTERVAL '1 hour'
GROUP BY bucket
ORDER BY bucket;time_bucket and quantile_cont give an exact 95th percentile per 15-minute bucket. Elasticsearch's percentiles aggregation estimates it.
Search + join
PUT customers
{
"settings": { "index.mode": "lookup" },
"mappings": { "properties": {
"customer_id": { "type": "long" },
"plan": { "type": "keyword" }
} }
}
FROM logs
| WHERE @timestamp > NOW() - 1 hour
AND MATCH(message, "timeout refused")
| LOOKUP JOIN customers ON customer_id
| STATS errors = COUNT(*) BY plan
| SORT errors DESC-- customers stay in PostgreSQL, logs stay in Parquet files
ATTACH 'host=localhost dbname=postgres user=postgres'
AS pg (TYPE postgres);
CREATE VIEW lake_logs AS
SELECT * FROM read_parquet('/data/lake/logs_*.parquet');
CREATE INDEX lake_logs_idx ON lake_logs
USING inverted (message english, ts) INCLUDE (customer_id);
SELECT c.plan, count(*) AS errors
FROM lake_logs_idx l
JOIN pg.customers c ON c.id = l.customer_id
WHERE l.message @@ to_tsquery('timeout OR refused')
AND l.ts > now() - INTERVAL '1 hour'
GROUP BY c.plan
ORDER BY errors DESC, c.plan;The logs stay in Parquet files and the customers in PostgreSQL; SereneDB joins them at query time, and s3:// paths work the same way. In Elasticsearch the right side of LOOKUP JOIN has to be an index in lookup mode.
Move off Elasticsearch in three steps
Run SereneDB next to Elasticsearch, compare the results on the same data, then switch traffic. Migrating from Elasticsearch maps match, match_phrase, fuzzy queries and aggregations to SQL.
Start SereneDB
Run it next to Elasticsearch and connect with psql or any PostgreSQL driver.
$ docker run -d --name serenedb -p 7890:7890 -e POSTGRES_PASSWORD=<password> serenedb/serenedbBring the data in
Index files and tables where they are, send OpenTelemetry logs over OTLP/HTTP, or point
_bulkjobs at the Elasticsearch-compatible API.$ psql -h localhost -p 7890 -U postgresPort queries and dashboards
Rewrite Query DSL as SQL, rebuild dashboards in Grafana on its PostgreSQL data source, and retire Elasticsearch once the results match.
$ psql -h localhost -p 7890 -U postgres -c "SELECT count(*) FROM logs WHERE message @@ 'timeout'"
Its source code is available under the AGPLv3, the SSPL and the Elastic License 2.0, and the default binaries ship under the Elastic License. Elastic left Apache 2.0 for the SSPL and the Elastic License in 2021 and added the AGPL option in 2024; some features need a paid subscription. SereneDB is licensed under Apache 2.0.
No. OpenSearch is a separate project that AWS forked from Elasticsearch 7.10.2 in 2021, after Elastic's license change, and it is now run by the OpenSearch Software Foundation. Elastic still develops Elasticsearch. The OpenSearch comparison covers SereneDB against the fork.
Yes. The Elasticsearch-compatible API accepts _bulk and _search with match, match_phrase, term, range and bool queries, terms, date_histogram and metric aggregations, and scroll, and every index it creates is also a SQL table.
For simple keyword search, often. PostgreSQL's built-in full-text search ranks with ts_rank, which is not BM25, and extensions such as pg_search and pg_textsearch add BM25 inside PostgreSQL. SereneDB is a separate server that speaks the PostgreSQL protocol and ranks with BM25; the Postgres full-text search page compares the options.
Not when it already sits in Parquet, CSV or JSON files on S3 or disk, in Iceberg tables, or in PostgreSQL or ClickHouse: you create a view and an inverted index over it, and the rows stay at the source. SereneDB stores the index itself and refreshes it with REINDEX or on a schedule. Data that lives only in Elasticsearch has to be exported first.
No. Kibana reads only from Elasticsearch. Dashboards move to Grafana, which connects to SereneDB through its built-in PostgreSQL data source and runs SQL queries.
SereneDB searches with IResearch, a C++ library with BM25 and other scorers, phrase, fuzzy and regex queries, highlighting and stemming in 34 languages. Its results against Lucene and Tantivy are published in the Search Benchmark Game, and SearchBench compares it with Elasticsearch on 1B logs.
Yes. Point SereneDB at the same files, tables or log stream that your Elasticsearch ingest reads, compare the results of the same queries, and move traffic once they match.