QuestDB is an open-source, column-oriented time-series database that you query with SQL. It adds time-series extensions such as SAMPLE BY and LATEST ON, accepts high-rate ingestion through the InfluxDB Line Protocol (ILP), and speaks the PostgreSQL wire protocol, so tools like psql and Grafana can connect to it directly. In this tutorial you will run QuestDB on Ubuntu 24.04 with Docker, create a partitioned table with deduplication, ingest data over HTTP and with the Python client, query it, and manage old partitions.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user with sudo privileges.
  • At least 2 GB of RAM and SSD storage.
  • Docker Engine installed from Docker's official repository, with your user in the docker group.
  • An SSH client on your computer to reach the web console through a tunnel.

Step 1 - Starting QuestDB with Docker

QuestDB exposes several ports, each for a different interface:

PortPurpose
9000Web console, REST API and ILP over HTTP
9009ILP over TCP
8812PostgreSQL wire protocol
9003Health check and metrics

Create a directory on the host for the database files, so the data survives container upgrades:

sudo mkdir -p /var/lib/questdb

Start the container. The ports are published on 127.0.0.1 only, because the open-source edition's web console has no login by default and the PostgreSQL endpoint uses a well-known default password:

docker run -d \
  --name questdb \
  --restart unless-stopped \
  -p 127.0.0.1:9000:9000 \
  -p 127.0.0.1:9009:9009 \
  -p 127.0.0.1:8812:8812 \
  -p 127.0.0.1:9003:9003 \
  -e QDB_PG_USER=admin \
  -e QDB_PG_PASSWORD='your_strong_password' \
  -v /var/lib/questdb:/var/lib/questdb \
  questdb/questdb:latest

Replace your_strong_password with a password of your choice. QuestDB maps environment variables that start with QDB_ to settings in its server.conf, so QDB_PG_PASSWORD sets pg.password.

Check that the container is running and read its startup log:

docker ps --filter name=questdb
docker logs questdb 2>&1 | tail -n 20

The log ends with lines listing the addresses of the web console and the PostgreSQL endpoint. Confirm the REST API answers:

curl -sG http://127.0.0.1:9000/exec --data-urlencode "query=SELECT now()"
{"query":"SELECT now()","columns":[{"name":"now","type":"TIMESTAMP"}],"timestamp":-1,"dataset":[["2026-09-25T10:20:31.415000Z"]],"count":1}

The data files, configuration (conf/server.conf) and logs now live under /var/lib/questdb on the host.

Step 2 - Opening the web console through an SSH tunnel

The web console is the easiest place to write SQL. Because the port is bound to localhost, open an SSH tunnel from your computer, replacing your_user and your_server_ip:

ssh -L 9000:127.0.0.1:9000 your_user@your_server_ip

Keep that session open and browse to http://localhost:9000 on your computer. You get a SQL editor, a table list and a chart view for query results.

Step 3 - Creating a partitioned table

QuestDB tables for time-series data have a designated timestamp column that defines the physical order of rows, and a partitioning scheme that splits data into one directory per day, month or hour. Queries that filter on time only read the partitions they need.

Run the following statement in the web console:

CREATE TABLE sensors (
    ts TIMESTAMP,
    device_id SYMBOL,
    location SYMBOL,
    temperature DOUBLE,
    humidity DOUBLE,
    battery SHORT
) TIMESTAMP(ts)
PARTITION BY DAY WAL
DEDUP UPSERT KEYS(ts, device_id);

Key parts of this definition:

  • SYMBOL is for repeated strings such as device IDs or locations. QuestDB stores them as integers pointing to a dictionary, which is much faster than plain strings to filter and group.
  • TIMESTAMP(ts) makes ts the designated timestamp.
  • PARTITION BY DAY WAL creates one partition per day and uses the write-ahead log, which allows concurrent writers and is required for deduplication.
  • DEDUP UPSERT KEYS(ts, device_id) makes ingestion idempotent: if a device sends the same reading twice (for example after a retry), the second row replaces the first instead of creating a duplicate. The key list must include the designated timestamp.

Choose the partition size according to your ingestion rate: DAY suits most metrics workloads, HOUR fits very high rates, and MONTH fits sparse data.

Step 4 - Ingesting data over HTTP

QuestDB accepts InfluxDB Line Protocol on the /write endpoint of port 9000. Each line has a table name, symbol values as tags, columns as fields and an optional timestamp in nanoseconds. Send two rows from the server with curl:

curl -s -X POST http://127.0.0.1:9000/write --data-binary $'sensors,device_id=dev-001,location=madrid temperature=21.4,humidity=48.0,battery=87i\nsensors,device_id=dev-002,location=barcelona temperature=23.1,humidity=61.5,battery=14i'

A successful write returns HTTP 204 with an empty body. Without a timestamp, QuestDB uses the time the row was received. If a table does not exist yet, ILP creates it automatically, but defining it first as in Step 3 lets you control partitioning, types and deduplication.

Check the rows arrived:

curl -sG http://127.0.0.1:9000/exec --data-urlencode "query=SELECT device_id, temperature, battery FROM sensors"
{"query":"SELECT device_id, temperature, battery FROM sensors","columns":[...],"dataset":[["dev-001",21.4,87],["dev-002",23.1,14]],"count":2}

WAL tables apply writes asynchronously. If a query right after a write returns no rows, wait a second and run it again.

Step 5 - Ingesting data with the Python client

For real applications, use one of the official clients, which batch rows and handle retries. Install the Python client in a virtual environment:

sudo apt install python3-venv
python3 -m venv ~/questdb-venv
~/questdb-venv/bin/pip install questdb

Create a script that generates simulated readings for 20 devices over the last hour:

nano ~/ingest.py
import random
from datetime import datetime, timedelta, timezone

from questdb.ingress import Sender

now = datetime.now(timezone.utc)
locations = ["madrid", "barcelona", "valencia", "sevilla"]

with Sender.from_conf("http::addr=127.0.0.1:9000;") as sender:
    for minute in range(60):
        at = now - timedelta(minutes=minute)
        for n in range(1, 21):
            sender.row(
                "sensors",
                symbols={
                    "device_id": f"dev-{n:03d}",
                    "location": locations[n % len(locations)],
                },
                columns={
                    "temperature": round(random.uniform(15, 30), 2),
                    "humidity": round(random.uniform(30, 70), 1),
                    "battery": random.randint(5, 100),
                },
                at=at,
            )

print("Sent 1,200 rows")

Run it:

~/questdb-venv/bin/python ~/ingest.py
Sent 1,200 rows

The with block flushes all pending rows when it exits. Because of the DEDUP UPSERT KEYS(ts, device_id) clause, if a client resends a batch with the same timestamps (for example after a network error), QuestDB keeps a single row per timestamp and device.

Step 6 - Querying time-series data

QuestDB supports standard SQL plus a few time-series clauses. Run these queries in the web console.

Average and maximum temperature per location in 10-minute buckets over the last hour:

SELECT ts, location, avg(temperature) AS avg_temp, max(temperature) AS max_temp
FROM sensors
WHERE ts > dateadd('h', -1, now())
SAMPLE BY 10m;

SAMPLE BY groups rows into time buckets based on the designated timestamp, the equivalent of a GROUP BY on a rounded time.

The latest reading of each device:

SELECT device_id, ts, temperature, battery
FROM sensors
LATEST ON ts PARTITION BY device_id;

LATEST ON returns the most recent row for each value of device_id without scanning the whole table, which is ideal for status dashboards.

Devices whose latest battery level is below 20 percent:

SELECT device_id, location, battery
FROM (
    SELECT device_id, location, battery
    FROM sensors
    LATEST ON ts PARTITION BY device_id
)
WHERE battery < 20
ORDER BY battery;

Always include a condition on the designated timestamp when you query large tables. QuestDB uses it to skip whole partitions, which is the single biggest factor in query speed.

Step 7 - Connecting with psql

Any PostgreSQL client can query QuestDB on port 8812, which is useful for scripts and for Grafana's PostgreSQL data source. Install the client:

sudo apt install postgresql-client

Connect with the user and password you set in Step 1. The database name is qdb:

psql -h 127.0.0.1 -p 8812 -U admin -d qdb -c "SELECT count(*) FROM sensors;"
 count
-------
  1202
(1 row)

Step 8 - Managing disk space with partitions

Time-series data grows continuously, so decide how long to keep it. List the partitions of the table and their size:

SELECT name, numRows, diskSizeHuman FROM table_partitions('sensors');

Drop partitions older than 90 days:

ALTER TABLE sensors DROP PARTITION WHERE ts < dateadd('d', -90, now());

Dropping a partition deletes its directory, so it frees disk space immediately and is much cheaper than a DELETE-style operation. Run it periodically, for example from a cron job that calls psql with the statement above.

Step 9 - Upgrading QuestDB

Because the data lives on the host in /var/lib/questdb, upgrading means replacing the container. Pull the new image, then remove and recreate the container with the same docker run command from Step 1:

docker pull questdb/questdb:latest
docker stop questdb && docker rm questdb

Read the release notes before upgrading across major versions, and back up /var/lib/questdb first while the container is stopped.

Troubleshooting

The container keeps restarting. Check docker logs questdb. A common cause is another service already using one of the ports; find it with sudo ss -tlnp | grep -E ':(9000|9009|8812|9003)'.

Rows sent over ILP do not appear. ILP reports type errors in the HTTP response and in the QuestDB log, for example when a column receives a string where it expects a number. Check docker logs questdb 2>&1 | grep -i error. Remember that integers need the i suffix in line protocol (battery=87i).

psql fails with password authentication failed. The QDB_PG_* variables are read when the container starts. If you changed them, recreate the container. If /var/lib/questdb/conf/server.conf sets pg.password, the environment variable takes precedence.

Queries are slow. Make sure the query filters on the designated timestamp and that repeated strings use SYMBOL instead of VARCHAR.

Conclusion

QuestDB is now running on Ubuntu 24.04 in Docker, with a partitioned, deduplicated table fed over HTTP and from Python, and queried with SAMPLE BY and LATEST ON. As next steps, add a Grafana PostgreSQL data source pointing at port 8812 to build dashboards, send metrics from Telegraf using its InfluxDB output against QuestDB's /write endpoint, and back up /var/lib/questdb to off-site storage on a schedule.