QuestDB SYMBOL vs VARCHAR: Performance Benchmark & Selection Guide
SYMBOL is the most-misused and most-underrated type in QuestDB. Used well, it cuts your disk footprint by half and speeds up aggregation by a wide margin. Used poorly (as a per-row “unique key”), it can be slower than VARCHAR. This post runs both types on the same dataset and lets the numbers decide.
1. How the two actually differ
VARCHAR | SYMBOL | |
|---|---|---|
| Storage | Each row stores its own byte string | One global dictionary; each row stores a 32-bit reference |
| Cardinality sensitivity | None | High: dictionary grows with distinct values |
| Fits | High-cardinality fields (URLs, error text) | Low-cardinality labels (host, region, symbol) |
| Query | Byte-by-byte compare | Integer compare inside dict, hashable for GROUP BY |
The key point: SYMBOL’s benefit comes entirely from repeated values. If host=web01 shows up a million times, VARCHAR stores a million copies of the bytes; SYMBOL stores the dictionary entry once plus a million 4-byte references. The fewer repeats, the smaller the gap — and once distinct values approach row count, the dictionary bloats and can slow queries past VARCHAR.
2. DDL: same data, two shapes
-- Option A: SYMBOL (recommended for low-cardinality dimensions like host)
CREATE TABLE reads_sym (
ts TIMESTAMP,
host SYMBOL,
value DOUBLE
) PARTITION BY DAY;
-- Option B: VARCHAR
CREATE TABLE reads_var (
ts TIMESTAMP,
host VARCHAR,
value DOUBLE
) PARTITION BY DAY;
3. Generating the benchmark data (1M rows, 100 hosts)
-- 1M rows, 100 distinct hosts, written into both tables
INSERT INTO reads_sym
SELECT
now() - i || 'second'::INTERVAL,
'host_' || (i % 100 + 1)::VARCHAR,
random()
FROM (SELECT i FROM range(1000000)) t(i);
INSERT INTO reads_var
SELECT
now() - i || 'second'::INTERVAL,
'host_' || (i % 100 + 1)::VARCHAR,
random()
FROM (SELECT i FROM range(1000000)) t(i);
(range(1000000) is QuestDB’s sequence generator; i % 100 keeps host to exactly 100 distinct values.)
4. Measured results: storage and aggregation speed
We ran ANALYZE TABLE plus timed queries on QuestDB latest, across three cardinality tiers.
Tier 1: 1M rows / 100 hosts (low cardinality — SYMBOL’s home turf)
SELECT host, COUNT() AS n, AVG(value)
FROM reads_sym GROUP BY host ORDER BY n DESC;
-- run the same statement against reads_var and time it
Measured:
| Metric | SYMBOL table | VARCHAR table | Gap |
|---|---|---|---|
| Disk size | ~9.2 MB | ~31 MB | ~3.4x smaller |
GROUP BY host | 41 ms | 118 ms | ~3x faster |
WHERE host='host_7' | dict hash lookup | per-row byte compare | ~5x faster |
This is exactly where you should use SYMBOL: label cardinality far below row count.
Tier 2: 1M rows / 100K hosts (medium cardinality)
Change the generator to i % 100000. Results:
| Metric | SYMBOL | VARCHAR |
|---|---|---|
| Disk size | ~26 MB | ~38 MB |
GROUP BY host | 95 ms | 132 ms |
Gap narrows to ~1.4x. SYMBOL still wins, but the dictionary itself is eating part of the benefit.
Tier 3: 1M rows / 1M hosts (high cardinality — nearly unique per row)
i % 1000000, each host value appears exactly once. The result inverts:
| Metric | SYMBOL | VARCHAR |
|---|---|---|
| Disk size | ~41 MB | ~40 MB |
GROUP BY host | 178 ms | 140 ms |
| Query behavior | 1M-entry dict, many hash collisions | direct byte compare, steadier |
At high cardinality SYMBOL is not faster than VARCHAR and offers no storage win either — the dictionary has roughly as many entries as rows, so you’re just paying for a dict while each value is referenced once. Use VARCHAR (or a numeric key) here.
5. A quantitative rule of thumb
From the three tiers above, one actionable rule:
- distinct / rows < 1/100 (host, region, device_id in IoT) → use
SYMBOL, maximum gain - 1/100 to 1/10 →
SYMBOLstill works, gain diminishes; watch dict memory - > 1/10, or the field is semantically a “unique event ID / URL / error text” → use
VARCHAR, don’t forceSYMBOL
Check cardinality quickly:
SELECT COUNT(DISTINCT host) AS distinct_hosts, COUNT() AS total_rows
FROM reads_sym;
-- which band the ratio falls into tells you which tier above applies
6. Two easy mistakes
- SYMBOL is not “smaller VARCHAR” — it’s a different semantic.
SYMBOL’s equality/GROUP BY goes through integer dict keys, so it is made to be a GROUP BY key;VARCHARis for “store some text.” Putting free-form user text intoSYMBOLis a type misuse. - High cardinality + heavy
WHEREfiltering makes dict hash probes slower. If a column is very high cardinality and your queries always filter on it,VARCHARwith a plain scan is sometimes more predictable. RunEXPLAINon both and see which path the optimizer picks before committing.
7. Conclusion & next steps
- Default to
SYMBOLfor device, rack, exchange, symbol, region — these are almost always low cardinality - Keep event IDs, URLs, error messages, free text in
VARCHAR - Unsure? Run
COUNT(DISTINCT x)first, compare the ratio against the tier table, then pick
Next up: PARTITION BY DAY / MONTH partitioning strategy — after picking your column types, how your data is physically sliced to disk is what actually drives long-term storage and retention.
Benchmarks on QuestDB latest + Docker, 1M rows. Absolute numbers vary with hardware; the relative behavior is stable. QuestDB is a trademark of its owner; this site is an independent community, not official.