01 / The idea
Count different riders, not taps.
A transit line records every tap: card, station, day. Taps are easy to count. Riders are not: the same card taps in and out, comes back tomorrow, uses two stations. To count different cards exactly, you keep every card id you have seen in a hash set, per station, per day, for every question anyone might ask later.
HyperLogLog keeps a small array of registers instead, and never stores a card. Each card’s hash picks a register, and the rest of the hash gives a rank: one more than its run of leading zeros. A register keeps the largest rank it has seen. Seeing the same card again changes nothing, so repeats cannot inflate the count.
Philippe Flajolet and colleagues published it in 2007; it can estimate “cardinalities of
> 109 with a typical accuracy (standard error) of 2%, using 1.5 kB of memory.”
Redis documents it for questions like “How many unique visits has this page had on this
day?”, and its PFMERGE combines sketches. The idea extends to how many different
products sold, searches typed, or devices seen.
02 / Name the rule
The longest run of zeros says how many.
Salvatore Sanfilippo, who wrote Redis’s version, explains it with a coin: if you spent your day flipping one and your longest run was three heads, you did not flip very often; if it was twenty, you flipped all day. A hash is a row of fair coin flips. About half of all different cards have a hash starting with 1, a quarter with 01, an eighth with 001. Seeing a rank of k suggests roughly 2k different cards.
One register would be wildly noisy, so the first bits of the hash split cards among many registers, and the estimate combines them all with a harmonic mean. With m registers the typical error is about 1.04 ÷ √m: 26% with 16 registers, 1.6% with 4,096, 0.81% with 16,384. When many registers are still empty, counting the empty ones is more accurate, and the estimate switches to that.
Merging is the reason it is everywhere. Take the larger register in each position of two sketches, and you have exactly the sketch you would have built from both sets of cards. Union costs one pass, and nobody needs the cards.
Why can’t I just add two stations’ counts?Distinct counts overlap
A rider who taps in at Harbour and out at Market is one of Harbour’s riders and one of Market’s. Adding the two counts counts that rider twice, and a commuter over a month thirty or sixty times. Exact sets would need a union to answer; so do sketches, and for sketches the union is cheap.
What sketches cannot do well is intersection: how many riders used both stations. The usual trick, both counts minus the union, subtracts estimates with their errors, and small overlaps drown in them.
03 / Follow one operation
Twelve taps, two stations.
The animation uses a deliberately tiny sketch, 16 registers a station, so every register is visible and the error is large. A morning’s taps arrive at Harbour and Market, some cards at both.
Before you watch, predict whether a card tapping for the first time must always change a register, and what the line’s estimate will be next to the two stations’ estimates added up. The animation replays what the TypeScript example recorded. Try it lets you tap your own.
Keep the longest run. Merge, never add.
HyperLogLog · each register keeps the largest rank a card has given it
One day, two stations
| Sketch | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Harbour | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| Market | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
Taps 0 Raised 0 Kept 0 Merged 0
Sixteen registers per station, all zero.
Each station keeps one sketch for the day: 16 registers of one byte each. A tap never stores the card; the card’s hash may raise one register, and that is all.
Reduced motion: choose a scene to see its completed state.
Read this scene
Each station keeps one sketch for the day: 16 registers of one byte each. A tap never stores the card; the card’s hash may raise one register, and that is all.
Each station keeps one sketch for the day: 16 registers of one byte each. A tap never stores the card; the card’s hash may raise one register, and that is all.
Registers raised so far: 0. Kept: 0. Merged: 0.
Watch restarts when you return. Step through keeps your selected step. Try it starts from empty sketches each time you open it.
04 / Read the shape
One sketch, one ridership report.
Basic form is HyperLogLog: add, merge, and count over 2precision registers, recording every register raised, kept, or merged. In the wild wraps it in TransitRidership, which checks card
ids, stations, and days, keeps a sketch per station per day, and estimates riders at a
station or on the whole line over up to 31 days by merging.
A HyperLogLog of 2^precision one-byte registers over a 32-bit hash. Add keeps the largest rank per register, merge keeps the larger register of two sketches, and count turns the registers into an estimate, with a correction for small counts. Every register touched can be recorded.
export type Step =
/** raise or keep: an item's hash picked a register and a rank; the register keeps the larger. */
| { kind: 'raise' | 'keep'; index: number; rank: number; before: number; after: number }
/** merge: another sketch's register was larger, so this one takes it. */
| { kind: 'merge'; index: number; before: number; after: number }
/** sketch: the next sketch merged into a total. */
| { kind: 'sketch'; station: string; day: string };
const encoder = new TextEncoder();
// FNV-1a over the UTF-8 bytes, then a final mix, so Go computes the same 32-bit hash.
function hash(text: string): number {
let h = 0x811c9dc5;
for (const byte of encoder.encode(text)) {
h ^= byte;
h = Math.imul(h, 0x01000193) >>> 0;
}
h ^= h >>> 16;
h = Math.imul(h, 0x85ebca6b) >>> 0;
h ^= h >>> 13;
h = Math.imul(h, 0xc2b2ae35) >>> 0;
h ^= h >>> 16;
return h >>> 0;
}
// HyperLogLog estimates how many different items it has seen, in a fixed array of small
// registers. Each item's hash picks a register with its first bits, and the rest of the hash
// gives a rank: one more than its run of leading zeros. A run of k zeros turns up about once
// in 2^k different items, so the largest rank each register has seen says roughly how many
// items reached it. Seeing an item again changes nothing.
export class HyperLogLog {
/** Bits of the hash that pick a register: 2^precision registers. */
readonly precision: number;
#registers: Uint8Array;
constructor(precision = 12) {
if (!Number.isInteger(precision) || precision < 4 || precision > 16)
throw new RangeError('precision must be a whole number from 4 to 16');
this.precision = precision;
this.#registers = new Uint8Array(1 << precision);
}
/** Returns true when a register rose, which means the estimate may have changed. */
add(item: string, steps?: Step[]): boolean {
const h = hash(item);
const index = h >>> (32 - this.precision);
const rest = (h << this.precision) >>> 0;
const rank = Math.min(Math.clz32(rest), 32 - this.precision) + 1;
const before = this.#registers[index];
const after = Math.max(before, rank);
this.#registers[index] = after;
steps?.push({ kind: after > before ? 'raise' : 'keep', index, rank, before, after });
return after > before;
}
// Taking the larger register everywhere gives exactly the sketch of both sets together:
// a union, without knowing a single item. Returns how many registers rose.
merge(other: HyperLogLog, steps?: Step[]): number {
if (other.precision !== this.precision)
throw new RangeError('only sketches with the same precision can merge');
let raised = 0;
other.#registers.forEach((value, index) => {
const before = this.#registers[index];
if (value <= before) return;
this.#registers[index] = value;
steps?.push({ kind: 'merge', index, before, after: value });
raised++;
});
return raised;
}
/** The estimated number of different items, with a standard error of about 1.04 ÷ √registers. */
count(): number {
const m = this.#registers.length;
let sum = 0;
let zeros = 0;
for (const register of this.#registers) {
sum += 1 / 2 ** register;
if (register === 0) zeros++;
}
const alpha = m === 16 ? 0.673 : m === 32 ? 0.697 : m === 64 ? 0.709 : 0.7213 / (1 + 1.079 / m);
let estimate = (alpha * m * m) / sum;
// Few items leave empty registers, and counting those is more accurate than the harmonic mean.
if (estimate <= 2.5 * m && zeros > 0) estimate = m * Math.log(m / zeros);
// Near 2^32, different items start sharing whole 32-bit hashes; correct for that.
else if (estimate > 2 ** 32 / 30)
estimate = -(2 ** 32) * Math.log(1 - Math.min(estimate, 2 ** 32 - 1) / 2 ** 32);
return Math.floor(estimate + 0.5);
}
registers(): number[] {
return Array.from(this.#registers);
}
} // Step is one register touched. Kind is "raise" or "keep" (an item's hash picked register
// Index and a Rank; the register keeps the larger), "merge" (another sketch's register was
// larger, so this one takes it), or "sketch" (the next sketch merged into a total, from
// Station on Day).
type Step struct {
Kind string `json:"kind"`
Index int `json:"index"`
Rank int `json:"rank,omitempty"`
Before int `json:"before"`
After int `json:"after"`
Station string `json:"station,omitempty"`
Day string `json:"day,omitempty"`
}
// hash is FNV-1a over the UTF-8 bytes, then a final mix, so TypeScript computes the same
// 32-bit hash.
func hash(text string) uint32 {
h := uint32(0x811c9dc5)
for i := 0; i < len(text); i++ {
h ^= uint32(text[i])
h *= 0x01000193
}
h ^= h >> 16
h *= 0x85ebca6b
h ^= h >> 13
h *= 0xc2b2ae35
h ^= h >> 16
return h
}
func record(steps *[]Step, step Step) {
if steps != nil {
*steps = append(*steps, step)
}
}
// HyperLogLog estimates how many different items it has seen, in a fixed array of small
// registers. Each item's hash picks a register with its first bits, and the rest of the hash
// gives a rank: one more than its run of leading zeros. A run of k zeros turns up about once
// in 2^k different items, so the largest rank each register has seen says roughly how many
// items reached it. Seeing an item again changes nothing.
type HyperLogLog struct {
precision int
registers []uint8
}
func NewHyperLogLog(precision int) (*HyperLogLog, error) {
if precision < 4 || precision > 16 {
return nil, errors.New("precision must be a whole number from 4 to 16")
}
return &HyperLogLog{precision, make([]uint8, 1<<precision)}, nil
}
// Add returns true when a register rose, which means the estimate may have changed.
func (s *HyperLogLog) Add(item string, steps *[]Step) bool {
h := hash(item)
index := int(h >> (32 - s.precision))
rank := min(bits.LeadingZeros32(h<<s.precision), 32-s.precision) + 1
before := int(s.registers[index])
after := max(before, rank)
s.registers[index] = uint8(after)
kind := "keep"
if after > before {
kind = "raise"
}
record(steps, Step{Kind: kind, Index: index, Rank: rank, Before: before, After: after})
return after > before
}
// Merge takes the larger register everywhere, which gives exactly the sketch of both sets
// together: a union, without knowing a single item. It returns how many registers rose.
func (s *HyperLogLog) Merge(other *HyperLogLog, steps *[]Step) (int, error) {
if other.precision != s.precision {
return 0, errors.New("only sketches with the same precision can merge")
}
raised := 0
for index, value := range other.registers {
before := s.registers[index]
if value <= before {
continue
}
s.registers[index] = value
record(steps, Step{Kind: "merge", Index: index, Before: int(before), After: int(value)})
raised++
}
return raised, nil
}
// Count estimates the number of different items, with a standard error of about 1.04 ÷
// √registers.
func (s *HyperLogLog) Count() int {
m := float64(len(s.registers))
sum, zeros := 0.0, 0
for _, register := range s.registers {
sum += 1 / float64(uint64(1)<<register)
if register == 0 {
zeros++
}
}
var alpha float64
switch len(s.registers) {
case 16:
alpha = 0.673
case 32:
alpha = 0.697
case 64:
alpha = 0.709
default:
alpha = 0.7213 / (1 + 1.079/m)
}
estimate := alpha * m * m / sum
two32 := float64(uint64(1) << 32)
if estimate <= 2.5*m && zeros > 0 {
// Few items leave empty registers, and counting those is more accurate than the harmonic mean.
estimate = m * math.Log(m/float64(zeros))
} else if estimate > two32/30 {
// Near 2^32, different items start sharing whole 32-bit hashes; correct for that.
estimate = -two32 * math.Log(1-min(estimate, two32-1)/two32)
}
return int(math.Floor(estimate + 0.5))
}
func (s *HyperLogLog) Registers() []int {
out := make([]int, len(s.registers))
for i, r := range s.registers {
out[i] = int(r)
}
return out
}
func (s *HyperLogLog) Precision() int { return s.precision } Reading the TypeScriptMath.clz32 and a Uint8Array
Registers are a Uint8Array, one byte each. h >>> (32 - precision) takes the first bits as the register, h << precision shifts them
away, and Math.clz32 counts the leading zeros of what is left.
count ends with Math.floor(estimate + 0.5) rather than Math.round, so the rounding rule is written out and Go’s matches it
exactly.
Reading the Gobits.LeadingZeros32 and uint32 shifts
h << s.precision on a uint32 drops the high bits by
itself, and bits.LeadingZeros32 counts the zeros. Registers are a []uint8.
Dates are checked by hand, with a regular expression and a days-in-month table, so a day such as 2026-02-29 is refused in both languages the same way, without depending on either language’s date library.
What would I normally use in application code?Your database probably has it
Neither TypeScript nor Go ships HyperLogLog. For a few thousand distinct values, a Set is simpler and exact. For more, reach for what your data store offers:
Redis’s PFADD, PFCOUNT, and PFMERGE, or your
database’s approximate distinct count if it has one. Many build on Google’s HyperLogLog++,
which uses 64-bit hashes and a sparse representation while counts are small.
05 / Try a decision
A month of riders.
The daily sketches already exist. The monthly report asks a different question of them. Decide how to answer it before the feedback tells you.
06 / Follow the cost
4 KB a sketch, whatever the ridership.
Here is every operation at a glance, with m registers. The rest of this section measures accuracy and a month of taps.
| Operation | Time | Extra space | What it assumes |
|---|---|---|---|
| Record a tap | O(1) | O(1) | Hash the card, pick a register, keep the larger rank. |
| Estimate one sketch | O(m) | O(1) | Read all m registers: 4,096 at precision 12. |
| Merge k sketches | O(k·m) | O(m) | Keep the larger register in each position; a month of 20 stations is 600 sketches. |
| One sketch | — | O(m) | However many riders: 4 KB at precision 12, with a typical error of 1.04 ÷ √m, about 1.6%. |
| Exact set instead | O(1) average | O(n) | Every one of the n different card ids, kept for every combination of stations and days you might ask about. |
In the animation, 16 registers estimated Harbour’s 6 riders as 5. At precision 12, 4,096 registers, the lesson’s TypeScript sketch counted a million different cards five times with a root-mean-square error of 1.2%, and 100 cards twenty times with 1.4%. At 10,000 cards the error rose to 3.0%, near where the estimate switches away from counting empty registers. At precision 14 the typical, root-mean-square error stayed under 1% at every size, though one set of 100 cards was 3% off.
Then a month: 20 stations, 30 days, and 1.2 million taps by 189,937 different cards, kept in 600 sketches of 4,096 bytes, 2,457,600 bytes in all. Merging all 600 estimated 185,579 riders for the month, 2.3% low. Adding the station-day estimates would have said 1,164,354, and adding the line’s daily estimates 570,148. A single exact set for the month holds 189,937 card ids, nearly 2 MB of id text before any set overhead: a little less than the sketches, but it answers only that one question. Exact sets for every station on every day, which answer everything the sketches answer, hold 1,164,115 ids and 12.2 MB of text before overhead, about five times the sketches. These are counts from the lesson’s TypeScript example, not timings.
07 / Give it a real job
Merge by station and day.
TransitRidership keeps one sketch per station per day and merges on demand: a station
on a day reads one sketch, the line reads every station, and a range merges up to 31 days. It
checks card ids, known stations, and real dates, and counts exact taps beside the estimates.
Two cautions. Report estimates as estimates, with their error, because the line’s figure and the stations’ figures will never add up and should not. And a sketch holds no card ids, but it is not anonymised data: anyone holding a card id can hash it and see which register it would raise. If that matters, salt the hash, for example per day, and accept that sketches with different salts cannot merge.
Build UIs?See the sketch already behind your analytics numbers, and the day you build a users table that must not add up its rows.
Where it already is in your components
If your product sends events to Google Analytics 4, you have read these numbers every day. Google’s post “Unique count approximation in Google Analytics” says GA4 uses HyperLogLog++ “to estimate cardinality for most used metrics including Active Users and Sessions”, at precision 14 for Active Users and Total Users and 12 for Sessions. That is 16,384 registers and a typical error of about 0.81% for your active users, and 4,096 registers, about 1.6%, for sessions.
Every table you build from counts like these inherits a rule from the structure: a users column has no total. The same person opens several pages, so they sit in several rows, and adding the rows counts them again, just as adding Harbour and Market said 10 riders where the line held 7. A total has to come from the source, merged and deduplicated, never from adding up rows.
When you have to own it
Now the analytics page is yours, inside your own product’s dashboard. The API takes a date
range and returns an estimated count of unique users for each page, plus one total for all
pages. The server computes both by merging daily sketches, the way TransitRidership merges station-days, and reports the precision it used.
Show every figure as “about N” with its typical error, 1.04 ÷ √m for that precision, so nobody treats 4,012 against 4,030 as a real difference. Put the server’s deduplicated total above the table, labeled as users across all pages, and give the table no total row. Never add rows on the client, not even for a filtered handful of pages: that total is a new merge, so it is a new request.
The date inputs change the query, so each change means a fetch. Wait for the typing to pause, cancel the request the previous range started, and drop any answer that arrives for a range nobody is looking at any more.
An “about N users” figure for a cell or a card. It takes the estimate and the precision the API reports, and shows the typical error, 1.04 ÷ √m, beside the number.
// A distinct count from a HyperLogLog is an estimate. Say so, and show its typical error.
export function UniqueUsers({
estimate,
precision
}: {
/** Estimated different users, from the server's sketch. */
estimate: number;
/** The precision the API reports: its sketch has 2^precision registers. */
precision: number;
}) {
// Typical relative error is 1.04 ÷ √m: about 1.6% at precision 12, 0.81% at 14.
const relative = 1.04 / Math.sqrt(2 ** precision);
const within = Math.round(estimate * relative);
return (
<span>
about {estimate.toLocaleString()} users{' '}
<small>
(typically within ±{within.toLocaleString()},{' '}
{relative.toLocaleString(undefined, { style: 'percent', maximumSignificantDigits: 2 })})
</small>
</span>
);
}
08 / Make the call
Ask how many different, and how exactly.
Reach for HyperLogLog when you count distinct things at scale, can live with an error of a percent or two, and want to combine counts across time, places, or servers by merging.
Look elsewhere when the question changes. For exact counts of a manageable set, a hash set is exact and simple. To ask whether one card
has been seen, a Bloom filter answers membership.
To ask how often each card or tag appeared, a count-min sketch estimates frequencies. And a
batch job over stored taps can run an exact COUNT(DISTINCT) when nobody is waiting
for it.
09 / Take the idea with you
Explain it without saying “HyperLogLog.”
“I turn every card into a random-looking number. Its first few bits choose a box, and I note in that box the longest run of zeros I have ever seen after those bits. Long runs are rare, so long runs in many boxes mean many different cards. A card seen twice writes the same thing twice. To combine stations, I keep the bigger note in each box.” That describes the mechanism. The name is what you call it in a review.
Before moving on, explain three things without the name: why a repeat tap changes nothing, why a brand-new card sometimes changes nothing too, and why the line’s riders are a merge rather than a sum. Then find a count of distinct users in your code, and ask whether it keeps every id to get there.
Connections to follow nextRelated lessons
- Count-min sketch estimates how often, where this estimates how many different.
- Bloom filter answers whether one item was seen.
- Hash set counts distinct items exactly, one entry each.