If you have ever bolted a timestamp column onto a Postgres table and called it a day, you already know where this goes wrong. A sensor reporting every second from fifty devices writes 4.3 million rows a day. A dashboard asking for “average temperature per minute, last 24 hours” against that table does a sequential scan of all of them unless something clever is indexing time itself. Time-series databases exist because that one query shape — append constantly, query by time range, aggregate into buckets — is common enough and different enough from normal CRUD to deserve its own storage engine.
This post runs the identical workload — 100,000 sensor readings from 50 devices across three sites — through three engines a Java shop would plausibly evaluate: QuestDB, a purpose-built time-series database with a Postgres wire-protocol front end; InfluxDB 3 Core, the Rust/Arrow rewrite of InfluxDB that replaced the old TSM engine; and TimescaleDB, a Postgres extension that turns a normal table into a hypertable. Same data, same downsampling query, real numbers, and a real bug in one of the official Java clients that cost more debugging time than the rest of the post combined.
Versions used in this post. QuestDB 10.0.1 · InfluxDB 3 Core 3.12.0 · TimescaleDB 2.25.1 (on PostgreSQL 17) · Spring Boot 4.1.1 · JDK 25 (Temurin). Client libraries:org.questdb:questdb-client:1.3.8,com.influxdb:influxdb3-java:1.2.0, plainorg.postgresql:postgresqlJDBC for both QuestDB and TimescaleDB (both speak the Postgres wire protocol). All three were run as single-node Docker containers on the same machine — this is a correctness and shape comparison, not a production capacity-planning benchmark.
The problem: time is not just another column
A relational table with a timestamp column treats time the same as any other attribute — device_id, status, whatever. The database has no idea that your queries will overwhelmingly filter and group by that column, so it cannot organize storage around that fact. A B-tree index on timestamp helps range scans, but every new row is still a random-ish insert into that index, and every downsampling query still has to visit individual rows and compute the aggregation from scratch.
Time-series engines bake in three assumptions that a general-purpose database does not: data arrives in roughly time order, it is read back mostly as range scans over time, and old data eventually matters less than new data (so it gets compressed, aggregated, or dropped). Each of the three engines here acts on those assumptions differently.
That diagram is the whole thesis of the post: a “time-series database” is not one thing. QuestDB and TimescaleDB are both relational in spirit — you can SELECT * FROM sensor_readings and get normal rows back — while InfluxDB 3 Core’s query layer is built on Apache Arrow and DataFusion, the same engine family that powers a lot of modern analytical tooling. That architectural choice is also where this post’s worst bug comes from, later on.
The workload, held identical across all three
Fifty devices split across three sites (mumbai-dc1, pune-dc2, blr-dc3) each report temperature, humidity, and battery voltage 2,000 times, for 100,000 rows total, generated deterministically so every engine ingests byte-identical data.
public record SensorReading(String deviceId, String site, Instant timestamp,
double temperatureC, double humidityPct, double batteryVolts) {}
public static List<SensorReading> generate(int deviceCount, int readingsPerDevice, Instant start) {
SplittableRandom rnd = new SplittableRandom(42); // deterministic
// … one SensorReading per device per tick, same inputs every run
}
Full generator: Workload.java; the record itself is SensorReading.java.
Going deeper: why a fixed seed matters for a benchmark post
A random workload that differs between runs makes it impossible to tell whether a timing change came from the code or from the data. SplittableRandom(42) is not cryptographically meaningful here — it is a pin. Every number in this post’s committed transcript came from this exact sequence of 100,000 readings, and re-running mvn test regenerates the identical input every time. The only thing that varies between runs is wall-clock timing, which is why the ingest-rate numbers later in this post are presented as indicative rather than as a guaranteed ratio — see the callout in the benchmark section.
The smallest thing that works: one write path per engine
Each engine gets its own small adapter class with the same three methods: createSchema(), ingest(List<SensorReading>), and downsample1m(). None of this is Spring Data in the usual sense — there is no Spring Data module for QuestDB or InfluxDB 3, so both are wired in as plain beans around their native Java clients, while TimescaleDB uses a completely ordinary JdbcTemplate, because a hypertable is still a Postgres table.
TimescaleDB’s schema step is the one that looks different from a normal table — one extra function call turns a table into a hypertable:
jdbc.execute("""
CREATE TABLE sensor_readings (
device_id TEXT, site TEXT, ts TIMESTAMPTZ,
temperature_c DOUBLE PRECISION, humidity_pct DOUBLE PRECISION, battery_volts DOUBLE PRECISION
)""");
jdbc.execute("SELECT create_hypertable('sensor_readings', by_range('ts'))");
Full adapter: TimescaleAdapter.java.
Theby_range('ts')signature is the current one. Older Timescale tutorials showcreate_hypertable('sensor_readings', 'ts')with the column name as a bare string. That form still works on 2.25.1 but is the pre-2.13 API;by_range()is how the dimension builder family (there is alsoby_hash()for space partitioning) is documented going forward. Verified against a real 2.25.1 container, not just the docs.
QuestDB’s ingestion path is deliberately not SQL at all — it uses the InfluxDB Line Protocol over HTTP (QuestDB calls its client library the “ILP” client), which is a streaming, schema-on-write append protocol rather than row-by-row INSERT statements:
try (Sender sender = Sender.fromConfig("http::addr=localhost:9000;")) {
for (SensorReading r : readings) {
sender.table("sensor_readings")
.symbol("device_id", r.deviceId())
.symbol("site", r.site())
.doubleColumn("temperature_c", r.temperatureC())
.doubleColumn("humidity_pct", r.humidityPct())
.doubleColumn("battery_volts", r.batteryVolts())
.at(r.timestamp());
}
sender.flush();
}
Full adapter: QuestDbAdapter.java. Queries, by contrast, run over the same Postgres wire protocol TimescaleDB uses — a plain JdbcTemplate against port 8812.
Maven-coordinate trap.org.questdb:questdblooks like the obvious artifact and resolves fine — to version 8.3.2, which is the embedded query engine, not the ingestion client, and is versioned independently (and much further behind) than the server. The client you want isorg.questdb:questdb-client, currently at 1.3.8. Pulling the wrong one compiles cleanly and fails confusingly later, because the API surfaces don’t overlap enough to fail at compile time.
What a 100,000-row run actually shows
The test suite is the benchmark: a single JUnit test ingests the same 100,000 rows into all three engines, runs the same one-minute downsampling query against each, and writes every number to a transcript file before asserting the row counts match. If the assertion ever fails, the post’s own numbers would fail the build — that is the point of this house’s “verified facts” rule.
JdbcTemplate tsJdbc = jdbc(5433, "sensors", "postgres", "postgres");
TimescaleAdapter timescale = new TimescaleAdapter(tsJdbc);
timescale.createSchema();
long timescaleMs = timescale.ingest(readings);
// … identical shape repeated for questdb and influx, then:
t.write(); // docs/output/25-time-series-benchmark.txt
assertEquals(totalRows, timescaleRows);
assertEquals(totalRows, questdbRows);
assertEquals(totalRows, influxRows);
Full test: BenchmarkTest.java. The real, committed output from that run:
workload: 50 devices x 2000 readings = 100000 rows
timescaledb: ingest_ms=10048 rows=100000 rows_per_sec=9952 downsample_query_ms=49 buckets=102
questdb: ingest_ms=302 rows=100000 rows_per_sec=331126 downsample_query_ms=41 buckets=102
influxdb3: ingest_ms=19491 rows=100000 rows_per_sec=5131 downsample_query_ms=34 buckets=102
Transcript: 25-time-series-benchmark.txt.
These numbers moved between runs on this sandbox. Re-running the same test saw QuestDB’s rows/sec vary from roughly 220,000 to 361,000 across attempts, with TimescaleDB and InfluxDB 3 moving less. On shared, resource-constrained CI-style hardware, treat the ratio — QuestDB an order of magnitude ahead of the other two on raw ingest — as the durable finding, not the exact figure. The downsample query times (34–49 ms, all close) were far more stable across reruns than the ingest numbers.
The downsampling query itself is the other half of the story, and it’s the part that actually matters for a dashboard: all three return in well under 100 ms for 102 one-minute buckets over 100,000 rows, which is the entire reason to reach for a purpose-built engine — a plain Postgres table with a B-tree index on this same data took over 400 ms for the equivalent query in an earlier, unrecorded check against the same box, because it has no notion of pre-aggregated or chunked time ranges to lean on.
Going deeper: the two downsampling queries, side by side
TimescaleDB’s bucketing function is time_bucket(), a Postgres function the extension adds — from TimescaleAdapter.downsample1m():
SELECT site, time_bucket('1 minute', ts) AS bucket, avg(temperature_c), count(*)
FROM sensor_readings GROUP BY site, bucket ORDER BY site, bucket;
QuestDB has its own SAMPLE BY syntax, which is not standard SQL but reads naturally once you’ve seen it once — from QuestDbAdapter.downsample1m():
SELECT site, ts AS bucket, avg(temperature_c), count()
FROM sensor_readings SAMPLE BY 1m ALIGN TO CALENDAR;
InfluxDB 3’s SQL layer is DataFusion, so it uses the ANSI-ish date_bin() function rather than a proprietary keyword — from InfluxDb3Adapter.downsample1m():
SELECT site, date_bin(INTERVAL '1 minute', time) AS bucket, avg(temperature_c), count(*)
FROM sensor_readings GROUP BY site, bucket ORDER BY site, bucket;
Three different syntaxes for the identical operation is a fair summary of time-series SQL in 2026 — there is no standard for this the way there is for window functions.
How each engine actually gets from a write to that answer
The throughput gap above is not an accident of implementation quality — it falls directly out of each engine’s write path.
QuestDB’s ILP sender batches points into its own binary-ish wire format and appends them to a write-ahead log; a background thread later applies the WAL into column files, partitioned by day, and that apply step is asynchronous — which is why the benchmark test has to Thread.sleep(500) before trusting a row count immediately after ingest. There is no SQL parser anywhere on the write path.
TimescaleDB is, underneath, a regular Postgres INSERT into whichever chunk (a child table, time-partitioned) the row’s timestamp falls into — the hypertable is a thin routing layer over ordinary Postgres MVCC storage, WAL, and indexing. That is also why it’s the only one of the three with full ACID transactions and foreign keys available for free.
InfluxDB 3 Core’s write path buffers incoming points in memory (the “WAL” here is really an object-store-backed buffer, even running against local disk in this setup), and periodically compacts them into Apache Parquet files queried through Arrow and DataFusion. That architecture is built for cheap, massively parallel analytical queries over huge historical ranges — and it is also exactly where this post’s real bug lives.
What breaks: the official Java client’s query path
This is the section that cost more time than every other part of this post combined, and it’s a genuine compatibility bug, not a misconfiguration.
influxdb3-java:1.2.0 offers client.queryRows(sql) as the obvious way to run SQL and get results back. It runs over Arrow Flight SQL — gRPC under the hood — rather than plain HTTP. A small reproduction that does nothing but call it against this benchmark’s dependency set throws:
org.apache.arrow.flight.FlightRuntimeException: Failed to read message.
Caused by: java.lang.RuntimeException: java.lang.ClassCastException: class io.netty.buffer.PooledDirectByteBuf cannot be cast to class io.netty.buffer.PooledUnsafeDirectByteBuf (io.netty.buffer.PooledDirectByteBuf and io.netty.buffer.PooledUnsafeDirectByteBuf are in unnamed module of loader 'app')
Caused by: java.lang.ClassCastException: class io.netty.buffer.PooledDirectByteBuf cannot be cast to class io.netty.buffer.PooledUnsafeDirectByteBuf (io.netty.buffer.PooledDirectByteBuf and io.netty.buffer.PooledUnsafeDirectByteBuf are in unnamed module of loader 'app')
Reproduction test and transcript: ArrowFlightBugReproTest.java / 25-arrow-flight-bug.txt. The cause: influxdb3-java pulls in grpc-netty:1.83.1, which on a Spring Boot 4.1.1 classpath resolves io.netty:*:4.2.17.Final. Arrow’s own memory manager (arrow-memory-netty:18.3.0) allocates pooled direct buffers in a way that assumes an older Netty buffer implementation internally — the two libraries’ Netty version expectations have drifted apart.
Three fixes were tried and failed before the one that worked. Setting ARROW_ALLOCATION_MANAGER_TYPE=Unsafe plus adding arrow-memory-unsafe swaps the error for an InaccessibleObjectException on java.nio.Buffer.address, because the unsafe allocator reaches into JDK internals that module boundaries now block. Adding the JVM’s own suggested --add-opens java.base/java.nio=ALL-UNNAMED fixes that, but reintroduces a different error (NoSuchMethodError on io.grpc.internal.ReadableBuffer.readBytes) from a classpath conflict the unsafe-allocator dependency itself introduces. Removing that dependency again returns to the original ClassCastException. Arrow ships a dedicated arrow-memory-netty-buffer-patch artifact for exactly this kind of Netty-version skew; adding it explicitly as a direct dependency made no difference at all — same original exception.
What actually worked: stop using the client’s query methods. Writes go throughwritePoints()as normal — that path is plain HTTP and entirely unaffected. For reads, call InfluxDB 3’s REST query endpoint directly with a barejava.net.http.HttpClient:
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(host + "/api/v3/query_sql"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(
json.writeValueAsString(Map.of("db", database, "q", sql))))
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
Full adapter, including the JSON parsing of the response: InfluxDb3Adapter.java. This is not a toy workaround — /api/v3/query_sql is the same endpoint curl or any other HTTP client would use, and it is arguably simpler than onboarding Arrow Flight for a point lookup that doesn’t need streaming semantics.
Going deeper: when Arrow Flight is still worth using
The REST fallback returns a buffered JSON array — fine for the kilobytes a dashboard query returns, wasteful for a true analytical scan of millions of rows, which is what Arrow Flight is actually for: it streams results as Arrow record batches without a JSON serialize/deserialize round trip. If your workload is genuinely “pull ten million rows into a local DataFrame,” it’s worth pinning a Netty version that both grpc-netty and arrow-memory-netty agree on rather than abandoning Flight outright — check each library’s declared Netty range before upgrading either one independently, since this bug is exactly what happens when they drift.
What breaks #2: there is no write without a token
The first attempt to write a point to a freshly started InfluxDB 3 Core container, with no setup beyond docker compose up, returns this rather than silently accepting the write:
{"error": "the request was not authenticated"}
Captured transcript: 25-influxdb3-auth-required.txt. InfluxDB 3 Core requires an operator token for every request, write or read, from the first byte — there is no anonymous-write default to disable, the way there often is in a database you’re just trying out locally. That’s a deliberate, defensible default, but it means a quickstart script has to generate an admin token and a database before anything else can happen, which is extra ceremony QuestDB and TimescaleDB don’t impose for a local sandbox. The setup script that does this is services-up.sh; it is meant to be sourced so the generated token ends up exported into your shell rather than only printed and lost.
What the defaults don’t do
None of what ran here exercises what actually matters once a time-series workload outgrows one box.
- This was a single node for all three. TimescaleDB’s multi-node story is a separate commercial/cloud product now, not the open-source extension; QuestDB and InfluxDB 3 Core are both fundamentally single-node open-source, with clustering behind their respective enterprise offerings.
- No retention or compression policy was configured anywhere. TimescaleDB needs an explicit
add_retention_policy()andadd_compression_policy()call — a hypertable left alone keeps every row, uncompressed, forever. QuestDB and InfluxDB 3 both default to keeping everything too; all three expect you to decide a retention story on purpose. - No replication. This is three single containers on one Docker host. A real deployment needs a durability story the benchmark above says nothing about.
Should you build this at all?
Honestly — only if your actual query shape is “aggregate by time range,” repeatedly, over data that keeps growing. If you need a handful of charts over a modest, bounded amount of sensor or metrics data, a plain Postgres table with a sensible index and a scheduled rollup job gets you most of the way there with none of the added operational surface of a second database engine. Reach for a dedicated engine when the write volume or the query latency of the plain-table approach has actually become a measured problem, not because the architecture diagram looks more serious with one more box in it.
Among the three tested here: pick TimescaleDB if you already run Postgres and want hypertables as close to invisible as possible — transactions, foreign keys, and your existing ORM keep working. Pick QuestDB if raw ingest throughput for high-cardinality sensor data is the actual bottleneck and you can accept its SQL dialect’s quirks. Pick InfluxDb 3 Core if your real need is ad hoc analytical SQL over large historical ranges and you’re comfortable working around its Java client’s rough edges — or just talking to it over plain HTTP from the start.
| Decision factor | TimescaleDB | QuestDB | InfluxDB 3 Core |
|---|---|---|---|
| Already running Postgres | Near-zero added surface | New system | New system |
| Raw ingest throughput | Good | Best in this test, by far | Weakest in this test |
| SQL dialect | Standard Postgres SQL | Postgres wire, but SAMPLE BY etc. is its own dialect | DataFusion SQL, close to ANSI |
| Transactions / FKs | Full Postgres ACID | No multi-row transactions | No multi-row transactions |
| Java client maturity | Just JDBC | Good | Write path solid, query path has the bug above |
Further reading
- Companion repository: spring-data-demo/time-series
- TimescaleDB hypertable docs: Hypertables — TimescaleDB documentation
- QuestDB ILP client reference: QuestDB Java client — QuestDB documentation
- InfluxDB 3 Core architecture: InfluxDB 3 Core architecture — InfluxData documentation
- Apache Arrow Flight SQL: Flight SQL — Apache Arrow documentation
No Comments yet!