Blog/Open Source/Your COUNT(DISTINCT) Is Too Slow: Approximations and Sampling in Postgres
JUL 31, 2026/16 min readOpen Source

Your COUNT(DISTINCT) Is Too Slow: Approximations and Sampling in Postgres

I'm very impatient. I don't always care about the exact right answer. When I'm checking how many unique visitors hit a page, I don't need 497,536 — "about 497,000" is fine. If getting that answer takes 3 milliseconds instead of a second, I'll take the trade every time.

Postgres has several features for approximation and sampling that are worth knowing about, especially if you're working with huge data sets. Approximation options in Postgres range from built-in table sampling to algorithms for probabilistic data structures. This post walks through all of them using the same test data so you can compare and see when each one makes sense.

Follow along with a sample web analytics table

Everything in this post uses the same 10 million row test table. You can generate it yourself on Postgres 17 or 18+ with the hll and datasketches extensions installed. The benchmarks here were run on Postgres 18, and an Apple M-series machine with shared_buffers = 256MB, work_mem = 64MB, and max_parallel_workers_per_gather = 2 (the Postgres default) — a pretty standard laptop setup. Parallel scans are enabled for all queries equally. I also made a companion SQL gist with additional details. I think "too much SQL" wins me some kind of Snowflake employee award.

We're simulating a web analytics table, the kind of data you'd have from a product analytics tracker, or really any event-driven application. Each row here is a single page view with information about who visited, where they came from, and how fast the page loaded.

I built this test data set specifically so it has properties that exercise each approximation technique.

  • There are lots of distinct users for testing distinct counting
  • A few categorical columns like traffic channel for set operations (that is, comparing overlapping groups)
  • A numeric response time column with a skewed distribution for testing percentile calculations
CREATE TABLE page_views (
    id            bigint GENERATED ALWAYS AS IDENTITY,
    event_time    timestamptz NOT NULL,       -- spread over 90 days
    user_id       int NOT NULL,               -- 500K distinct users, power-law activity
    session_id    bigint NOT NULL,            -- ~5 events per session
    channel       text NOT NULL,              -- 5 traffic sources: direct, google, facebook, email, tiktok
    region        text NOT NULL,              -- 8 geographic regions
    page_path     text NOT NULL,              -- ~10K distinct URLs, Zipfian (some pages much hotter)
    response_ms   numeric(8,2) NOT NULL,      -- log-normal: median ~120ms, p99 ~1s, long tail to ~5s
    cost_cents    int                         -- ad spend per click; NULL for organic channels
);

Generating 10 million rows

This INSERT uses generate_series and random functions to fill the table which takes about 90 seconds on my laptop.

INSERT INTO page_views (event_time, user_id, session_id, channel, region, page_path, response_ms, cost_cents)
SELECT
    now() - (random() * 90)::int * interval '1 day'
          - (random() * 86400) * interval '1 second',
    least(floor(power(random(), 1.5) * 500000)::int + 1, 500000),
    floor(i / 5.0)::bigint + floor(random() * 1000000)::bigint,
    (ARRAY['direct','google','facebook','email','tiktok'])[
        CASE WHEN r < 0.35 THEN 1 WHEN r < 0.60 THEN 2
             WHEN r < 0.80 THEN 3 WHEN r < 0.92 THEN 4 ELSE 5 END
    ],
    (ARRAY['us_east','us_west','eu_west','eu_central','apac_east','apac_south','latam','africa'])[
        CASE WHEN r2 < 0.25 THEN 1 WHEN r2 < 0.45 THEN 2 WHEN r2 < 0.60 THEN 3
             WHEN r2 < 0.72 THEN 4 WHEN r2 < 0.82 THEN 5 WHEN r2 < 0.90 THEN 6
             WHEN r2 < 0.96 THEN 7 ELSE 8 END
    ],
    '/' || (ARRAY['blog','docs','pricing','product','about','api','demo','support'])[
        floor(random() * 8)::int + 1
    ] || '/page-' || floor(power(random(), 2) * 1250)::int,
    round(exp(4.8 + sqrt(-2.0 * ln(greatest(random(), 1e-10))) * cos(2.0 * pi() * random()) * 0.9)::numeric, 2),
    CASE WHEN r >= 0.35 AND r < 0.92 THEN floor(random() * 496)::int + 5 ELSE NULL END
FROM (
    SELECT i, random() AS r, random() AS r2
    FROM generate_series(1, 10000000) AS g(i)
) sub;

ANALYZE page_views;

The table ends up at about 1.4 GB on disk with 500,000 distinct users. The distribution should be somewhat realistic — a few power users generate way more traffic than everyone else, a handful of pages get most of the views, and response times have a long tail where most pages load fast but some are much slower. This makes it a good test for everything we'll cover.

The problem: Exact is expensive

Exact aggregates on big tables are slow in two different ways, and knowing which kind of slow you're dealing with determines which tool to reach for.

Aggregate stats — averages, sums, percentiles — are expensive because they have to read every row:

SELECT avg(response_ms) FROM page_views;
-- Time: 352 ms

SELECT percentile_cont(0.99) WITHIN GROUP (ORDER BY response_ms) FROM page_views;
--  986.39
-- Time: 2,617 ms

percentile_cont needs to sort all 10 million response time values to find the 99th percentile. Even with 64MB of work_mem and parallel workers, that's 2.6 seconds. Drop work_mem to 4MB (a common default) and it gets much worse — the sort spills to disk with over 200 MB of temp file I/O just to answer one question.

These queries are fine when you're doing a one-off analysis. They're probably not fine for dashboards that refresh every few seconds, or for tables that are 10 or 100 times larger than our test set. The rest of this post covers the tools that fix each case — starting with the simplest.

TABLESAMPLE: zero-setup sampling

For aggregate stats like averages and percentiles, you have a super simple option: just read less data. Postgres has a built-in TABLESAMPLE clause that returns a random subset of your table without needing any extensions or setup.

SELECT avg(response_ms) FROM page_views TABLESAMPLE SYSTEM(10);
-- Result: ~183   (actual: 182.25)
-- Time: 111 ms
-- Standard Postgres time: 352 ms

In our example, a 10% sample gives you a nearly identical average in about a third the time. Percentiles work too:

SELECT percentile_cont(0.99) WITHIN GROUP (ORDER BY response_ms)
FROM page_views TABLESAMPLE SYSTEM(10);
-- Result: ~985   (actual: 986.39)
-- Time: 250 ms
-- Standard Postgres time: 2,617 ms

The p99 estimate is within 0.3% of exact, and it ran 10x faster because Postgres only sorted ~1 million values instead of 10 million. You put TABLESAMPLE right after the table name and give it a percentage.

TABLESAMPLE has two sampling methods you can choose from.

SYSTEM samples at the page level. Postgres data is stored in 8KB pages on disk, and SYSTEM randomly includes or excludes entire pages. This is fast because it can skip whole chunks of the table without reading them. The downside is that if your data is physically clustered, you will get a biased sample, and there's no way to ensure it is correctly distributed.

BERNOULLI evaluates each row individually with a random number generator. This gives you a more evenly distributed sample but it's slower because it can't skip pages. The method is named after Jacob Bernoulli, the 17th-century Swiss mathematician who developed some of the math for probability.

-- SYSTEM: reads ~10% of pages, very fast
SELECT count(DISTINCT user_id) FROM page_views TABLESAMPLE SYSTEM(10);
-- Time: 217 ms
-- Standard Postgres time: 671 ms

-- BERNOULLI: evaluates every row, keeps ~10%
SELECT count(DISTINCT user_id) FROM page_views TABLESAMPLE BERNOULLI(10);
-- Time: 404 ms
-- Standard Postgres time: 671 ms

Where TABLESAMPLE falls down

Sampling can be a good fix for quicker aggregates. But what about distinct counts? We all know how slow full distinct counts are on huge tables.

SELECT count(DISTINCT user_id) * 10 FROM page_views TABLESAMPLE SYSTEM(10);
-- Result: 4,150,040   (actual: 500,000)
-- Time: 217 ms
-- Standard Postgres time: 671 ms

You might think "I sampled 10%, so multiply by 10 to get the full number." But this massively overcounts. Here's why: if a user appears in 20 rows across the full table, even a 10% sample is almost certain to catch at least one of those rows. So nearly all 500K users show up in your 10% sample. Multiplying by 10 gives you a number that's eight times too high.

TABLESAMPLE is best for: averages, sums, ballpark histograms, spot-checking your data during development, or as a quick smoke test. It may have some clustered data bias. If you need accurate distinct counts, you need a different approach.

HyperLogLog: streaming distinct counts

HyperLogLog (HLL) is a probabilistic algorithm that's been around since 2007. The core idea is simple: instead of storing every unique value, it observes patterns in the binary representation of hashed values to estimate how many distinct values it's seen.

The Postgres hll extension managed by Citus Data implements HLL as a native data type. When you use it, you'd typically store it as a column in a rollup table alongside your regular columns. It's a compact binary blob, so pretty space efficient. You can install it with:

CREATE EXTENSION IF NOT EXISTS hll;

Basic distinct count

Here's the simplest use — getting an approximate distinct count:

SELECT hll_cardinality(hll_add_agg(hll_hash_integer(user_id)))::int AS unique_users
FROM page_views;
-- Result: 497,209   (actual: 500,000, error: 0.6%)
-- Time: 320 ms
-- Standard Postgres time: 671 ms

That's 320 milliseconds vs 671 ms for exact COUNT(DISTINCT) which is about twice as fast on a single scan. Also HLL isn't just about raw speed on one query. The function names are a bit verbose but the pattern is always the same: hash the value, aggregate the hashes into an HLL, then ask for the cardinality.

Preaggregate for instant queries

The real power of HLL is that sketches are mergeable. This is the key concept that makes them different from just "a faster COUNT(DISTINCT)." You can build a separate HLL sketch for each day, store those sketches in a table, and then union them together at query time to get distinct counts across any date range.

-- Build daily HLL sketches (do this once a day in your pipeline)
CREATE TABLE daily_hll AS
SELECT
    date_trunc('day', event_time)::date AS event_date,
    hll_add_agg(hll_hash_integer(user_id)) AS users_hll
FROM page_views
GROUP BY 1;

-- Unique users over any date range — instant, doesn't touch the raw table
SELECT hll_cardinality(hll_union_agg(users_hll))::int AS unique_users_7d
FROM daily_hll
WHERE event_date >= current_date - 7;
-- Time: 0.1 ms
-- Standard Postgres time: 315 ms

The daily_hll table has one row per day. Each row is tiny — the HLL column in this example is about 1.3 KB per row regardless of how many users it represents. And querying this small rollup table instead of scanning millions of raw rows is what gets you from hundreds of milliseconds to sub-millisecond.

HLL sketches work as window functions too, making rolling unique counts trivial:

SELECT
    event_date,
    hll_cardinality(users_hll)::int AS daily_uniques,
    hll_cardinality(
        hll_union_agg(users_hll) OVER (
            ORDER BY event_date
            ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
        )
    )::int AS rolling_7_day_uniques
FROM daily_hll
ORDER BY event_date;
-- Time: 4 ms (all 91 days)
-- Standard Postgres time: 5,200 ms

Try computing a rolling 7-day unique user count with exact SQL — you'd need a correlated subquery that re-scans raw data for every output row. That took 7 seconds for just 5 days in our test — at that rate it would be over 2 minutes for all 91 days. With HLL, it's just a window function over a tiny column, done in 4 milliseconds.

Apache DataSketches: the full toolkit for probability

Apache DataSketches is a library of streaming algorithms originally developed at Yahoo for analytics at web scale. "Sketch" is a general term for any compact probabilistic data structure that summarizes a stream of data. Think of it like a compressed fingerprint of your dataset that you can still ask questions about.

The Postgres extension brings several sketch types, each designed for a different kind of question:

Sketch Question it answers
CPC (Compressed Probabilistic Counting) How many distinct values?
Theta How many distinct values overlap?
KLL What's the median? The p99? Show me a histogram.
Frequent Strings What are the heaviest items by count?
CREATE EXTENSION IF NOT EXISTS datasketches;

CPC: compact distinct counting

CPC is the DataSketches answer to "how many distinct values?" — the same question HLL answers, but CPC sketches can be more compact when stored and are thought to have slightly better accuracy at the same memory budgets.

SELECT cpc_sketch_get_estimate(cpc_sketch_build(user_id))::int AS approx_uniques
FROM page_views;
-- Result: 494,394   (actual: 500,000, error: 1.1%)
-- Time: 389 ms
-- Standard Postgres time: 671 ms

The cpc_sketch_build function is an aggregate — it works like count() or sum(), collapsing all the rows into a single sketch value. That sketch is an opaque binary blob, and you use cpc_sketch_get_estimate to extract the cardinality from it.

Theta sketches: set operations on distinct counts

Here's where things get interesting and where DataSketches really separates itself from plain HLL. Theta sketches let you do set operations — intersection, union, difference — on distinct count sketches.

Here's a question you simply cannot answer with HLL or COUNT(DISTINCT): "How many users visited via both Google and Facebook?"

WITH channel_sketches AS (
    SELECT
        channel,
        theta_sketch_build(user_id) AS sketch
    FROM page_views
    WHERE channel IN ('google', 'facebook')
    GROUP BY channel
)
SELECT
    theta_sketch_get_estimate(
        (SELECT sketch FROM channel_sketches WHERE channel = 'google')
    )::int AS google_users,
    theta_sketch_get_estimate(
        (SELECT sketch FROM channel_sketches WHERE channel = 'facebook')
    )::int AS facebook_users,
    theta_sketch_get_estimate(
        theta_sketch_intersection(
            (SELECT sketch FROM channel_sketches WHERE channel = 'google'),
            (SELECT sketch FROM channel_sketches WHERE channel = 'facebook')
        )
    )::int AS users_on_both;
-- google_users: 489,695 | facebook_users: 482,491 | users_on_both: 474,272
-- Time: 382 ms
-- Standard Postgres time: 2,499 ms (self-join on raw table)

With pre-aggregated COUNT(DISTINCT) rollups, answering overlap questions like this is impossible — you'd have to go back to the raw event table and do a self-join. With Theta sketches, you just intersect two pre-built sketches directly.

There's also theta_sketch_a_not_b for exclusive reach ("Google users who did NOT visit via Facebook") and you can compute a full pairwise overlap matrix across all channels.

KLL sketches: quantiles without sorting

"What's the p99 response time?" is a question that normally requires Postgres to sort your entire dataset. KLL sketches (named after their inventors Karnin, Lang, and Liberty, who published the algorithm in 2016) maintain a compact summary that can answer quantile, rank, and histogram questions without ever sorting.

SELECT
    kll_float_sketch_get_quantile(kll_float_sketch_build(response_ms::real), 0.50)::numeric(8,2) AS p50,
    kll_float_sketch_get_quantile(kll_float_sketch_build(response_ms::real), 0.90)::numeric(8,2) AS p90,
    kll_float_sketch_get_quantile(kll_float_sketch_build(response_ms::real), 0.99)::numeric(8,2) AS p99
FROM page_views;
-- p50: 121.80 | p90: 390.01 | p99: 1056.67
-- Time: 625 ms
-- Standard Postgres time: 2,617 ms

Compared to the exact values in this example (p50: 121.55, p90: 385.23, p99: 986.39), KLL is within about 7% on the tails. For monitoring and dashboards where you're looking at trends and alerting on big changes, this is more than accurate enough. And it ran four times faster.

You can also get full histograms with KLL using the probability mass function:

SELECT
    unnest(ARRAY['0-50ms','50-100ms','100-200ms','200-500ms','500-1000ms','1000ms+']) AS bucket,
    round(unnest(
        kll_float_sketch_get_pmf(
            kll_float_sketch_build(response_ms::real),
            ARRAY[50, 100, 200, 500, 1000]
        )
    )::numeric, 4) AS fraction
FROM page_views;

Like all the other sketch types, KLL is additive. Build one sketch per time bucket in your pipeline, then merge them at query time to get percentiles across any date range without resorting the original data.

Frequent Strings: most common values

The last sketch type finds the most common values in a column:

SELECT frequent_strings_sketch_result_no_false_negatives(
    frequent_strings_sketch_build(7, page_path), 50000
) FROM page_views;
-- Returns the ~76 most-visited page paths with estimated counts
-- Time: 560 ms
-- Standard Postgres time: 529 ms (GROUP BY page_path ORDER BY count(*) DESC LIMIT 76)

On a single full-table scan, the sketch is actually slightly slower than a plain GROUP BY. The value shows up when you preaggregate — build one sketch per day, then merge across any date range without re-scanning millions of rows:

-- Pre-build daily sketches (do once per day in your pipeline)
CREATE TABLE daily_freq_paths AS
SELECT
    date_trunc('day', event_time)::date AS event_date,
    frequent_strings_sketch_build(7, page_path) AS paths_sketch
FROM page_views
GROUP BY 1;

-- Top pages for the last 30 days — merge pre-built sketches
SELECT frequent_strings_sketch_result_no_false_negatives(
    frequent_strings_sketch_merge(7, paths_sketch), 50000
) FROM daily_freq_paths
WHERE event_date >= current_date - 30;
-- Time: 1 ms
-- Standard Postgres time: 1,027 ms (GROUP BY on raw table, 30-day filter)

That's 1,000x faster in our testing. The rollup table has 92 rows — one tiny sketch per day. The standard Postgres query has to scan 3+ million rows, hash them by page_path, sort, and return the top 76.

The preaggregate-then-merge pattern

This is the architectural pattern that ties everything together. The idea is:

  1. Build sketches once during your ETL or data pipeline, grouping by the dimensions you care about (date, channel, region, and so on)
  2. Store those sketches in a rollup table alongside regular aggregates like count(*) and sum(revenue)
  3. Query the rollup table at dashboard time: Filter to the dimensions you need, merge the sketches, get instant answers

Here's what the rollup table looks like:

CREATE TABLE analytics_rollup AS
SELECT
    date_trunc('day', event_time)::date AS event_date,
    channel,
    region,
    cpc_sketch_build(user_id) AS users_sketch,
    theta_sketch_build(user_id) AS theta_users,
    kll_float_sketch_build(response_ms::real) AS latency_sketch,
    count(*) AS total_events,
    sum(cost_cents) AS total_cost_cents
FROM page_views
GROUP BY 1, 2, 3;

This produces 3,680 rows (90 days x 5 channels x 8 regions). The table is 88 MB because it stores the full binary sketches. Compare that to the 1.4 GB raw table.

Now every analytics query hits the rollup instead of scanning the raw data:

-- Unique users in the last 7 days
SELECT cpc_sketch_get_estimate(cpc_sketch_union(users_sketch))::int AS unique_users_7d
FROM analytics_rollup
WHERE event_date >= current_date - 7;
-- Time: 4 ms
-- Standard Postgres time: 329 ms (COUNT(DISTINCT) on raw table, 7 days)

That's an 80x speedup — from over 300 milliseconds down to 4 milliseconds. And it works for all the sketch types:

-- p99 latency by channel (merged KLL sketches)
SELECT
    channel,
    kll_float_sketch_get_quantile(kll_float_sketch_merge(latency_sketch), 0.99)::numeric(8,2) AS p99_ms
FROM analytics_rollup
GROUP BY channel;
-- Time: 22 ms
-- Standard Postgres time: 3,932 ms (percentile_cont by channel on raw table)

Don't overlook the hardware payoff here

Another thing worth pointing out is that sketches don't just make queries faster, they also make your hardware requirements smaller.

Think about what exact queries are doing under the hood. COUNT(DISTINCT) builds a hash table in memory that grows with the number of unique values. percentile_cont() sorts the entire column in memory. When these structures exceed your work_mem setting, Postgres writes temp files to disk — and that's when things really slow down.

Here's what we saw with work_mem = 4MB:

Query Temp files? Time
percentile_cont(0.99) (exact) Yes — spills to disk 2,620 ms
COUNT(DISTINCT user_id) (exact) No (uses index-only scan) 946 ms
cpc_sketch_build(user_id) No — fixed ~2 KB regardless of input 273 ms
kll_float_sketch_build(response_ms) No — fixed ~4 KB regardless of input 566 ms
Rollup query (prebuilt sketches) No — reads 576 buffers (4.5 MB total) 5 ms

The key insight: A CPC sketch uses 2-4 KB of memory whether you feed it 500 thousand or 500 million distinct values. A KLL sketch uses 4-8 KB whether you're computing percentiles over 10 million or 10 billion values. The memory footprint is bounded by the sketch's precision parameter, not by the size of your data.

What this means in practice:

  • You don't need to tune work_mem up to 256MB to avoid disk spills
  • You don't need fast NVMe storage for temp file I/O
  • Your analytics queries can run comfortably on a modest cloud instance
  • Dashboards stay responsive even on constrained hardware

The rollup query reads 576 buffers — that's 4.5 MB of data total. You could serve that from basically anything.


Keeping rollups fresh

Once you've got a rollup table, the next question is how to keep it up to date as new events arrive. There are a few good options depending on how much infrastructure you want.

Materialized views

The simplest approach is a materialized view — same SQL as the rollup table, but with a built-in refresh mechanism:

CREATE MATERIALIZED VIEW analytics_rollup AS
SELECT
    date_trunc('day', event_time)::date AS event_date,
    channel,
    region,
    cpc_sketch_build(user_id) AS users_sketch,
    theta_sketch_build(user_id) AS theta_users,
    kll_float_sketch_build(response_ms::real) AS latency_sketch,
    count(*) AS total_events,
    sum(cost_cents) AS total_cost_cents
FROM page_views
GROUP BY 1, 2, 3;

-- Add a unique index so CONCURRENTLY works
CREATE UNIQUE INDEX ON analytics_rollup (event_date, channel, region);

Now REFRESH MATERIALIZED VIEW CONCURRENTLY analytics_rollup; rebuilds it without blocking reads. You can put that in a cron job or call it from your pipeline after loading new data.

The tradeoff: Every refresh does a full recompute — it rescans all 10M rows and rebuilds every sketch from scratch. For our test table that takes about 10-15 seconds, which is fine for a nightly job. At hundreds of millions of rows it starts to hurt. For more on making matviews query-friendly, see Indexing Materialized Views in Postgres.

pg_incremental: process only new data

If full-table rescans become too expensive, pg_incremental gives you incremental processing. Instead of rebuilding the entire rollup, you define a pipeline that only processes rows that arrived since the last run:

-- Create the rollup table
CREATE TABLE analytics_rollup (
    event_date date, channel text, region text,
    users_sketch bytea, latency_sketch bytea,
    total_events bigint, total_cost_cents bigint
);

-- Process each day's data exactly once, appending sketch rows to the rollup
SELECT incremental.create_time_interval_pipeline(
    pipeline_name := 'rollup_pipeline',
    time_interval := '1 day',
    source_table_name := 'page_views',
    start_time := (SELECT min(event_time) FROM page_views),
    command := $$
        INSERT INTO analytics_rollup
        SELECT
            date_trunc('day', event_time)::date AS event_date,
            channel,
            region,
            cpc_sketch_build(user_id) AS users_sketch,
            kll_float_sketch_build(response_ms::real) AS latency_sketch,
            count(*) AS total_events,
            sum(cost_cents) AS total_cost_cents
        FROM page_views
        WHERE event_time >= $1 AND event_time < $2
        GROUP BY 1, 2, 3
    $$
);

This is a natural fit for sketches because they're additive — you build a sketch for today's data and union it with yesterday's at query time. You don't have to re-scan historical rows. See pg_incremental: Incremental Data Processing in Postgres for the full details.

Sketches on remote data with pg_lake

Your event data doesn't have to live in a Postgres heap table for any of this to work. If your data already lives in object storage — synced from a warehouse, landed by a pipeline, or written directly by an application, pg_lake lets you query it from Postgres as an Apache Iceberg™ table. Under the hood, pg_lake uses a DuckDB columnar engine for the scan, so analytical queries on remote data are often dramatically faster than on a local heap table:

-- Iceberg table — data lives in object storage, queried via DuckDB columnar engine
CREATE TABLE page_views_iceberg (...) USING iceberg;

-- Exact distinct count on 10M rows
SELECT count(DISTINCT user_id) FROM page_views_iceberg;
-- Heap: 336 ms | Iceberg: 285 ms (similar — both hash-bound)

SELECT avg(response_ms)::numeric(8,2) FROM page_views_iceberg;
-- Heap: 221 ms | Iceberg: 16 ms (14x faster — columnar only reads one column)

The speedup depends on the query. For aggregates that scan a single column (avg, sum, filtered counts), Iceberg's columnar format is dramatically faster because it only reads the bytes it needs. For hash-heavy operations like COUNT(DISTINCT), both engines are doing similar work and performance converges.

But sketches may still matter — you can't query object storage in under a millisecond, no matter how fast the engine is. The rollup pattern we saw before can work on remote object stores. Scan Iceberg once, store sketches locally, and you'll have single-digit millisecond dashboard queries over data that lives remotely:

-- Scan remote data once, store sketch rollup in local Postgres (8 seconds)
CREATE TABLE analytics_rollup AS
SELECT
    date_trunc('day', event_time)::date AS event_date,
    channel,
    cpc_sketch_build(user_id) AS users_sketch,
    kll_float_sketch_build(response_ms::real) AS latency_sketch,
    count(*) AS total_events
FROM page_views_iceberg  -- data lives in S3
GROUP BY 1, 2;

-- Query the local rollup: <1 ms instead of scanning remote storage
SELECT cpc_sketch_get_estimate(cpc_sketch_union(users_sketch))::int
FROM analytics_rollup WHERE event_date >= current_date - 7;
-- Time: 4 ms
-- Standard Postgres time: 88 ms (COUNT(DISTINCT) on Iceberg table, 7 days)

The source data stays in object storage (cheap, shared with other tools). The sketch rollup lives in local Postgres (fast, tiny — 2.3 MB for 90 days of rollups vs 1.4 GB of raw data in our example). You can get sub-millisecond dashboard queries without copying terabytes of raw events into your database.

When NOT to approximate

Not everything should be approximated. Before the pitchforks come out in the comments section I'll add this warning. Here are cases where you should stick with exact:

Financial reporting, billing, compliance: If a number goes on an invoice or in a regulatory filing, you need exact. No debate.

Small tables: If you have under about 1 million rows, exact aggregates are fast enough — they'll come back in under 200ms. Don't add complexity to a problem that doesn't exist.

When you need individual rows: Sketches are aggregates. They can tell you "about 500K unique users" but they can't tell you which users. If you need WHERE user_id = X, you need the raw data.

Joins on approximate results: You can't JOIN on a sketch. You can TABLESAMPLE a table and then join it to something else, but be aware that sampling happens before the join — a 10% sample of two joined tables gives you ~1% of the matching rows.

"Close only counts in horseshoes and hand grenades."

Frank Robinson

Benchmark summary

All numbers from our 10M-row page_views table on Postgres 18 in Docker (Apple M-series, 256MB shared_buffers, 64MB work_mem, max_parallel_workers_per_gather = 2 unless noted). Parallel query is enabled for all benchmarks equally — both exact and sketch-based queries benefit from parallel table scans:

Method Time Standard Postgres time Result Error Notes
Exact COUNT(DISTINCT) 671 ms 500,000 0% Index-only scan
Exact (30 days only) 1,025 ms 497,618 0% Index on event_time, spills to disk
TABLESAMPLE SYSTEM(10) 217 ms 671 ms ~414K (x10=4.14M) 730% Terrible for distinct counts
TABLESAMPLE BERNOULLI(10) 404 ms 671 ms ~416K (x10=4.16M) 730% Same problem, slower
HLL (hll_add_agg) 320 ms 671 ms 497,209 0.6% Fixed memory, mergeable
CPC sketch 389 ms 671 ms 494,394 1.1% Slightly more compact storage
Theta sketch 370 ms 671 ms 497,746 0.5% Adds set operations
KLL sketch (p99) 625 ms 2,617 ms 1056.67 ~7% vs exact p99=986.39; 4x faster
Exact percentile_cont(0.99) 2,617 ms 986.39 0% Sorts entire column
Theta intersection 382 ms 2,499 ms 474,272 vs self-join on raw table
Rollup query (7 days) 4 ms 329 ms 373,440 ~3% 80x faster than raw scan

Comparison at a glance

  TABLESAMPLE HLL (postgresql-hll) DataSketches
What it does Reads a random subset of rows Distinct counting Distinct counting, set ops, quantiles, heavy hitters
Extension required No (built-in since PG 9.5) hll datasketches
Precomputable No (scan-time only) Yes Yes
Mergeable No Yes (hll_union_agg) Yes (all sketch types)
Distinct counts Poor (overcounts when extrapolated) Yes Yes (CPC, Theta)
Set operations No No Yes (Theta)
Quantiles / percentiles No No Yes (KLL)
Most common value / top-N No No Yes (Frequent Strings)
Typical error Varies with sample % ~2% at default ~1-3% at defaults
Error guarantee None (statistical) Mathematically bounded Mathematically bounded
Memory per sketch N/A ~1.3 KB ~1-4 KB
Best for Quick exploration, smoke tests Simple unique counts, rolling windows Multi-dimensional analytics, set ops, quantiles

Subscribe to our blog newsletter

Get the best, coolest and latest delivered to your inbox each week