Why one row per point is wrong — per-row label repetition and 60-byte overhead

Storing each point as a SQL row spends 60+ bytes of metadata to carry an 8-byte float, because the label set is repeated on every row of an append-only firehose.

Previously

We have a clean four-part tuple — now we try to store it the obvious way and watch the bytes explode.

Scene 02

Why one row per point is wrong

  1. Watch
  2. Try it
  3. Predict
  4. Capture
SAMPLES TABLE · per-row bytesBYTE STACK · coloured by categorymetriclabels (JSON)tsvaluehttp_request…{method=GET,status=200,path…171500…12473http_request…{method=GET,status=200,path…171500…12490http_request…{method=GET,status=200,path…171500…12507http_request…{method=GET,status=200,path…171500…12524http_request…{method=GET,status=200,path…171500…12541http_request…{method=GET,status=200,path…171500…12558r18226088106 Br28226088106 Br38226088106 Br48226088106 Br58226088106 Br68226088106 BRUNNING TOTAL0 B0 B127 B254 B382 B509 B636 BPROJECTION106 B/sample × 5,760 scrapes/day × 1,000 targets ≈ 0.6 GB/dayWaiting for the first scrape — every row will carry the same labels JSON.
What to watch for

Six scrapes of the same series stream in one at a time. Watch the labels JSON column (red) take more bytes than the row header, metric, ts, and value combined — every single row.

Continue unlocks when the animation finishes.
Implementation

Highlighted lines are the ones running in the diagram right now.

Storage.naiveInsert
one SQL row per point — labels JSON repeated every row
def naiveInsert(point):
# rowHeader 8 + metric 22 + labels 60 + ts 8 + value 8
# = ~106 B per row to carry an 8-byte float
db.execute(
'INSERT INTO points',
' (metric, labels, ts, value)',
' VALUES (?, ?, ?, ?)',
point.metric, # 'http_requests_total'
json(point.labels), # full label set, every row
point.ts,
point.value,
)
Storage.dedupeLabels
store the label set once; each row keeps a 2-byte ref
labelDict = {} # labelSet -> seriesId (16-bit)
def dedupeInsert(point):
key = canonical(point.labels)
if key not in labelDict:
labelDict[key] = nextSeriesId()
sid = labelDict[key] # 2-byte ref
db.execute(
'INSERT INTO points (sid, ts, value)',
' VALUES (?, ?, ?)',
sid, point.ts, point.value,
)
# row shrinks to ~28 B: header + 2B ref + ts + value
Storage.projectPerSeriesPerDay
multiply the per-row cost by the scrape count
def projectStorage(
seriesCount, labelBytes, scrapesPerDay,
):
rowHeader, metric, ts, value = 8, 22, 8, 8
perRow = rowHeader + metric + labelBytes + ts + value
perSeriesPerDay = perRow * scrapesPerDay
fleet = perSeriesPerDay * seriesCount
return perSeriesPerDay, fleet
# perRow is paid on every scrape — it doesn't amortize.
# fleet scales the per-series number by seriesCount.

Where this sits in Build a Prometheus-style time-series database

Scene 02 of 12. Storing each point as a SQL row spends 60+ bytes of metadata to carry an 8-byte float — the labels JSON repeats on every row of the firehose.

Up next. The labels are the easy waste to fix — store them once. But timestamps are 8 bytes each forever, and there are billions of them. Can we shrink those too?

All 12 scenes in Build a Prometheus-style time-series database · Every curriculum

Built with Arqly
Every scene in Build a Prometheus-style time-series database builds on the one before it.All 12 Build a Prometheus-style time-series database scenes