From RRDTool to Graphite: the genealogy

Open /var/lib/munin on any Indian VPS that has been running since 2015 and you will find thousands of .rrd files — fixed-size circular buffers, 64 KB each, holding five years of CPU samples in pre-allocated bins that never grow. Open /opt/graphite/storage/whisper/servers/web01/cpu/idle.wsp on a slightly newer host and you will find the same idea reimplemented in 2008 with worse compression and a name that looks like a filesystem path. Open a Prometheus chunk file from 2024 and you will find a column-store with Gorilla XOR compression, label-indexed inverted lookups, and zero pre-allocation — but the alert rule that fires off it still uses an irate() quirk that exists because vanilla Prometheus's first authors had to make their rate() behaviour interoperate with the mental model Graphite users had brought from the previous decade.

Two decisions from the 1990s — RRDTool's fixed-resolution-downsample-on-write and Graphite's dotted-hierarchical-name-as-identifier — are the bedrock that every modern time-series database is still arguing with. This chapter is the genealogy: what those tools got right, what they forced everyone after them to inherit, and which of the bugs you fight in Prometheus today are not bugs in Prometheus but echoes of choices made when a SPARC workstation with 64 MB of RAM was a respectable monitoring server.

RRDTool (1999) chose fixed-size pre-allocated round-robin databases with downsampling on write — every sample lost full resolution after a few hours. Graphite (2008) kept that storage shape (Whisper) and added dotted hierarchical names like servers.web01.cpu.idle as the identifier, plus a TCP line protocol for pushing samples. Modern TSDBs (Prometheus, VictoriaMetrics, Mimir) abandoned both — variable-resolution chunks and label-tuple identifiers — but the alert semantics, the dashboard query habits, and several PromQL quirks all exist because the migration path from Graphite-shaped operational thinking had to be smooth.

RRDTool — the round-robin database that taught everyone to downsample

In 1999, Tobias Oetiker at ETH Zurich was running MRTG, a Perl script that scraped SNMP counters from network gear and drew PNG graphs of router throughput. MRTG worked, but it had a problem: the underlying database was a flat text log that grew without bound. A switch monitored for two years had a multi-megabyte log file, queries to draw "last 24 hours" had to scan the whole thing, and operators were running out of disk on the monitoring boxes themselves. Oetiker's fix was structural: pre-allocate the storage at a fixed size, and downsample older data into coarser buckets on the way in. The result was RRDTool — Round-Robin Database — and it became, almost by accident, the storage substrate for an entire generation of monitoring tools.

A round-robin database is a circular buffer with rules. You declare it once at creation time: how many seconds per sample (the step), how many samples to keep at full resolution (the first RRA — round-robin archive), and how to consolidate older samples into coarser archives. A typical declaration for a five-minute-resolution router-traffic database with five years of retention looks like this: 5 min × 24 hours × 7 days at full resolution, plus 30 min × 24 hours × 31 days, plus 2 hours × 24 hours × 365 days, plus 1 day × 5 years. Total disk: roughly 64 KB per metric, fixed forever. The instant you create the file, every byte is allocated; the buffer wraps when it fills, the older RRAs receive consolidated data points (average / min / max / last) computed from the just-displaced fresh samples, and disk usage is bounded by mathematics rather than by retention policy.

This was a brilliant fit for the constraints of 1999. Disks were 9 GB; RAM was 64 MB; a single SNMP-polling process on a monitoring box might have to track ten thousand counters across a campus network and keep them queryable for a decade. Variable-size storage would have been a permanent capacity-planning problem. Fixed-size storage with on-write downsampling solved the problem at the cost of one painful, irreversible property: once a sample passed the boundary into a coarser archive, the original full-resolution data was gone forever. An operator looking at "what was the exact bandwidth at 14:32:45 on a Tuesday two years ago" would get back the 2-hour or 1-day average for the bucket containing that timestamp, not the original sample. The full resolution was not stored compressed, not stored on a tier, not stored anywhere — it had been overwritten by the consolidation function on the way past the boundary.

Why on-write downsampling is irreversible: the round-robin archive is a fixed-size circular buffer. When a fresh sample lands in slot N at full resolution, slot N's previous occupant — which is being aged out of the full-resolution archive — is consolidated (averaged with its peers, or min'd, or max'd) into a single slot in the next coarser RRA, and then deleted from the full-resolution archive. There is no second copy, no backup tier, no append-only log behind the round-robin. The disk space was pre-allocated, the bookkeeping was trivial, the operational simplicity was enormous — and the trade-off was that every full-resolution sample older than the first RRA's window literally did not exist anymore. Modern TSDBs preserve full resolution at the cost of variable disk, and the entire migration path from RRDTool-shaped to Prometheus-shaped operations is a story of teams discovering that they want their forensic data back.

The shape of RRDTool's API forced a habit that survived everywhere else. Updates were always rrdtool update file.rrd N:value — a single timestamp, a single value, into a pre-declared series. There were no labels, no dimensions, no way to slice by host or service or merchant; if you wanted per-host CPU, you created one RRD file per host, and if you wanted per-host-per-core CPU, one file per host per core. The filesystem itself was the index, and operators ended up with directory trees like /var/lib/munin/<domain>/<host>/<plugin>-<resource>.rrd. The hierarchy was the namespace; the path was the identifier. Munin, Cacti, Ganglia, OpenNMS, Zabbix's pre-2.0 history — every one of them stored time-series data in RRDTool or an RRDTool clone, and the operational mental model that solidified during 2002–2010 was: one metric is one file, the filesystem is the index, and old data is automatically blurry.

RRDTool round-robin archive — fixed-size, downsample-on-writeA vertical stack of four circular buffers labelled RRA-1 (5min, 7 days), RRA-2 (30min, 31 days), RRA-3 (2h, 365 days), RRA-4 (1d, 5 years). Arrows show new samples entering RRA-1 at the head, and older samples being consolidated by an average function as they overflow into RRA-2, then RRA-3, then RRA-4. To the right, a notepad shows the disk footprint: 64 KB total, fixed, never grows. Bottom band: full-resolution data exists only inside RRA-1; everything older is averaged.RRDTool — round-robin archivefixed-size circular buffers, downsample on overflowRRA-15min × 2016 = 7 days2016 slotsavgRRA-230min × 1488 = 31 days1488 slotsavgRRA-32h × 4380 = 365 days4380 slotsavgRRA-41d × 1825 = 5 years1825 slotsdisk footprint~64 KB totalpre-allocated at createnever grows, never shrinksconsequences+ capacity-planning trivial+ no growth surprises- old data is averaged forever- forensic detail is gone- one file per series- no labels, no dimensions"What was the rate at 14:32:45 two years ago?" → only the daily average exists.Forensic detail is the price of fixed-size storage.
Illustrative — not measured data. RRDTool's round-robin archive is the architectural ancestor of every modern TSDB: it solved the "metrics fill the disk" problem of 1999 by pre-allocating storage and downsampling on write. The cost — irreversible loss of full-resolution history — is exactly the property modern column-stores were built to eliminate.

Two things RRDTool got right that everyone copied. First, the explicit step — sample interval as a first-class declaration, not a property of how often the application chose to call update. RRDTool would interpolate or reject samples that arrived at the wrong cadence; the database's notion of time was authoritative, and the application had to fit it. Every modern TSDB inherits this — Prometheus's scrape interval, VictoriaMetrics's dedup.minScrapeInterval, Influx's precision — and the discipline of "the storage layer owns the cadence" is what makes rate calculations consistent across deploys. Second, the explicit consolidation function (AVERAGE, MIN, MAX, LAST) chosen at creation time, which forced operators to think about what aggregation actually meant for their data. A counter aggregated as AVERAGE over 30 minutes is a nonsense number; an AVERAGE of latency samples and a MAX of bandwidth peaks are two different stories of the same workload. RRDTool taught a generation of operators that the function you compose with the storage matters. Modern TSDBs hide the consolidation function behind PromQL or Flux, but the same trade-offs are still there — quantile-from-histogram interpolation, downsampled-vs-raw rate divergence, recording-rule pre-aggregation — and operators who learned them on RRDTool fifteen years ago recognise the same shapes today.

Graphite — the dotted name that became the tax

By 2007, RRDTool's filesystem-as-index model was creaking. Orbitz Worldwide was running thousands of services across multiple data centres, and the operational team — led by Chris Davis — was finding that the path-based namespace (/var/lib/rrd/<dc>/<service>/<host>/<metric>.rrd) was the right shape for hierarchies but the wrong shape for the network protocol they wanted: a TCP socket where any service could push samples without provisioning anything. Davis open-sourced Graphite in 2008, and the design landed on three components that would shape the next decade of Indian SRE tooling: carbon (the receiver daemon that listened on TCP port 2003 for plain-text samples), whisper (a near-clone of RRDTool's storage format, but with append-only semantics that fixed RRDTool's worst write-amplification bugs), and graphite-web (a Django app that turned URL parameters into PNG charts). The carbon line protocol was disarmingly simple — metric.path.dotted value timestamp\n per line — and that simplicity is what made Graphite spread.

The line protocol fixed one class of operational problems and created another. On the win side: any service in any language could emit metrics. A Python script could socket.send(b"servers.web01.cpu.idle 88.4 1714032000\n"); a shell script could echo to nc graphite-host 2003; a Java app could open a socket and write the same string. There was no SDK, no schema, no registration step. By 2012, Etsy had open-sourced StatsD — a UDP receiver that aggregated counters and timers in front of Graphite — and the combination of "any language can push" + "the receiver does the aggregation" became the de-facto pattern for application-level metrics. Indian engineering teams adopting Graphite around 2013–2015 (Flipkart, Myntra, InMobi, Cleartrip) all built their first metrics stacks on this model: StatsD as the aggregator, Carbon as the receiver, Whisper as the storage, Graphite-web as the visualiser. The pattern shipped.

On the loss side: the dotted name was the entire identifier. There was no label tuple, no key-value metadata, no separation between metric name and dimensions. If you wanted per-host CPU you wrote servers.web01.cpu.idle, servers.web02.cpu.idle, servers.web03.cpu.idle. If you wanted per-host-per-core CPU you wrote servers.web01.cpu.0.idle, servers.web01.cpu.1.idle. If you wanted per-host-per-core CPU broken down by mode (user / system / iowait) you wrote servers.web01.cpu.0.idle, servers.web01.cpu.0.user, servers.web01.cpu.0.system, servers.web01.cpu.0.iowait. Every dimension was a path component, every combination was a separate Whisper file, and the order of the components encoded the dimension's identity. A query for "average CPU idle across all web servers" was averageSeries(servers.*.cpu.idle) — the asterisk wildcard expanding against the filesystem index, the averageSeries function aggregating the matched files. The query language was glob-on-the-namespace plus a library of functions; the schema was implicit in the order someone happened to choose for the dotted components.

This baked an enormous tax into every team that ran Graphite at scale. The first cost was immutability of the hierarchy: once a team chose servers.<host>.cpu.<core>.<mode>, switching to servers.<host>.<mode>.cpu.<core> to make some new query cheap meant rewriting every alert, every dashboard, every Whisper-file path. Hosts that had been emitting under one schema for two years had two years of data under the old paths and a one-day gap before the new paths started filling. The second cost was no easy way to slice on a non-leading component: a query like "show me CPU broken down by mode, summed across all hosts" required a function combination — groupByNode(servers.*.cpu.*.iowait, 4, "sumSeries") — that operators had to memorise, and the cost was proportional to the cardinality of the wildcard expansion, which the query author could not see in advance. The third cost, the one that hurt most: the namespace was intentional on the way in and archaeological on the way out. New engineers joining a team in 2014 inherited a Graphite tree built in 2012 by people who had since left, and the dotted convention was the only documentation. Hotstar's pre-2017 stack had Graphite trees with eight-level deep paths whose meaning was lost to time; the migration to Prometheus in 2017 had to unfold the implicit hierarchy into explicit labels.

Why labels won and why dotted names lost: a label tuple {service="payments", region="ap-south-1", endpoint="/checkout"} is unordered and self-describing. Any query can match on any label without caring where it appears in the tuple, the inverted index returns matching series in milliseconds, and adding a new dimension is non-destructive — old series are intact, new series add the new label, queries that don't mention the new label still work. A dotted name is ordered (the first component is privileged because every wildcard is anchored there) and implicit (the consumer has to know what the third component "means" without any schema). The label model is what makes Prometheus's service_http_requests_total{service="payments", region="ap-south-1", endpoint="/checkout"} a single series with four label values, where the same idea in Graphite would have been service.payments.ap-south-1.requests./checkout and would have failed at the slash in /checkout because dots-and-slashes-as-path-separator is not a label tuple, it is a filesystem.

The path-as-identifier model also made cardinality invisible. In Graphite, a team could emit a metric per (service × region × endpoint × user-id × payment-method) combination and the only signal that something had gone wrong was disk filling up on the carbon-cache box weeks later. The Whisper files were created lazily — first sample for a new path created a new file — and a misbehaving emitter could create hundreds of thousands of files in an afternoon. The cardinality cliff that Prometheus surfaces explicitly (the prometheus_tsdb_head_series gauge, the prometheus_target_metadata_cache_entries warning, the OOM-at-five-million-series threshold) was, in Graphite, a silent inode exhaustion problem. A Razorpay platform engineer in 2016 told a story of waking up to a Graphite host with 7.2 million Whisper files and an ext4 filesystem that had run out of inodes; the runaway emitter had been a mis-configured Java service appending the request UUID to the dotted path. The fix took a week. The fix in modern Prometheus would have taken minutes — topk(10, count by (__name__)(...)) would have surfaced the offending metric in one query — but the cardinality-as-budget framing did not exist yet because the substrate did not surface it.

Graphite's fourth contribution, less remembered, is the function library. Graphite-web shipped with hundreds of functions — nonNegativeDerivative, summarize, movingAverage, holtWintersForecast, keepLastValue — that operators composed into URL-parameter pipelines. The function names are the lineage of every modern TSDB query language: PromQL's rate() is essentially nonNegativeDerivative plus counter-reset detection, irate() maps to nonNegativeDerivative over the last two samples, aggregateOverTime() maps to summarize, predict_linear() maps to holtWintersForecast. The PromQL designers in 2012 explicitly studied Graphite's function library and chose to mirror the operations every dashboard already needed; the difference was that PromQL was a structured expression language while Graphite's was a URL-parameter blob. The functions were the same; the syntax-and-semantics layer was rewritten because Graphite's URL composition could not express arbitrary boolean logic over labels (because there were no labels). Every PromQL query you write is, in some sense, a Graphite function call wearing modern syntax.

A measurable demonstration — emit Graphite, then unfold it into labels

To make the Graphite-vs-modern shape concrete, run a small Python script that emits the same telemetry in two formats — Graphite's dotted-line protocol and Prometheus's labelled OpenMetrics — and observe what each shape forces you to do at query time. The script is end-to-end runnable: pip install prometheus-client, save the file, run it.

# graphite_vs_prometheus.py — show the same data in both lineages
import time, socket, threading
from collections import defaultdict
from prometheus_client import Counter, generate_latest, REGISTRY

# === Graphite-style dotted-name emission =================================
# Pretend we are a Razorpay payments service with three dimensions:
#   service x region x endpoint
# Graphite forces us to pick an order for the dotted name.

def graphite_line(service, region, endpoint, value, ts):
    # endpoint contains "/" — Graphite path separator collision.
    safe_ep = endpoint.replace("/", "_")
    name = f"app.{service}.{region}.requests.{safe_ep}"
    return f"{name} {value} {int(ts)}\n"

graphite_buf = []
def emit_graphite(service, region, endpoint, value):
    graphite_buf.append(graphite_line(service, region, endpoint, value, time.time()))

# === Prometheus-style labelled emission ==================================
# The same data, but as a label tuple — order is irrelevant.

prom_counter = Counter(
    "app_requests_total",
    "Requests handled",
    labelnames=["service", "region", "endpoint"],
)

def emit_prom(service, region, endpoint, value):
    prom_counter.labels(service=service, region=region, endpoint=endpoint).inc(value)

# === Generate a realistic Razorpay-shaped workload =======================
events = [
    ("payments", "ap-south-1", "/checkout",   42),
    ("payments", "ap-south-1", "/refund",      9),
    ("payments", "ap-south-2", "/checkout",   38),
    ("payments", "ap-south-2", "/refund",      4),
    ("ledger",   "ap-south-1", "/post",      120),
    ("ledger",   "ap-south-1", "/reconcile", 14),
]
for s, r, e, n in events:
    emit_graphite(s, r, e, n)
    emit_prom(s, r, e, n)

# === Query 1: total requests for /checkout across regions ================
# In Graphite: glob the namespace; the filesystem is the index.
def graphite_query_checkout_total():
    total = 0
    for line in graphite_buf:
        # "app.<svc>.<region>.requests._checkout 42 ..."
        path, value, _ = line.split()
        parts = path.split(".")
        if len(parts) == 5 and parts[4] == "_checkout":
            total += int(value)
    return total

# In Prometheus: match on the endpoint label, ignore service/region.
def prom_query_checkout_total():
    total = 0
    for fam in REGISTRY.collect():
        if fam.name != "app_requests":
            continue
        for s in fam.samples:
            if s.name == "app_requests_total" and s.labels.get("endpoint") == "/checkout":
                total += int(s.value)
    return total

# === Query 2: I just learned about a new dimension — payment_method ======
# Graphite: must rewrite every emitter to add a new path component.
# Prometheus: add a new label, old series remain intact.

print(f"events emitted (each side): {len(events)}")
print(f"graphite query /checkout total : {graphite_query_checkout_total()}")
print(f"prometheus query /checkout total: {prom_query_checkout_total()}")
print()
print("Graphite payload (first 3 lines):")
for line in graphite_buf[:3]:
    print(f"  {line.rstrip()}")
print()
print("Prometheus /metrics (filtered):")
for line in generate_latest(REGISTRY).decode().splitlines():
    if line.startswith("app_requests_total"):
        print(f"  {line}")

Sample run on a laptop:

events emitted (each side): 6
graphite query /checkout total : 80
prometheus query /checkout total: 80

Graphite payload (first 3 lines):
  app.payments.ap-south-1.requests._checkout 42 1714032123
  app.payments.ap-south-1.requests._refund 9 1714032123
  app.payments.ap-south-2.requests._checkout 38 1714032123

Prometheus /metrics (filtered):
  app_requests_total{endpoint="/checkout",region="ap-south-1",service="payments"} 42.0
  app_requests_total{endpoint="/checkout",region="ap-south-2",service="payments"} 38.0
  app_requests_total{endpoint="/refund",region="ap-south-1",service="payments"} 9.0
  app_requests_total{endpoint="/refund",region="ap-south-2",service="payments"} 4.0
  app_requests_total{endpoint="/post",region="ap-south-1",service="ledger"} 120.0
  app_requests_total{endpoint="/reconcile",region="ap-south-1",service="ledger"} 14.0

What the output is showing, line by line. The two query functions return the same number — 80 — because the same six events are being summed in both representations; the underlying data is identical. endpoint.replace("/", "_") is the first concrete cost of dotted names: the Graphite path separator collides with HTTP endpoint syntax, and the emitter has to mangle the name to escape it. The mangling is irreversible at query time — _checkout and _refund look like normal path components, and a future engineer reading the namespace cannot tell which underscores were originally slashes and which were just literal underscores in some other endpoint. graphite_query_checkout_total walks every line in the buffer, parsing the path and extracting the fifth component — this is exactly what graphite-web does internally when you write sumSeries(app.*.*.requests._checkout), except that production Graphite walks Whisper files instead of Python lines. prom_query_checkout_total filters on the endpoint label directly, ignoring the service and region dimensions; the same query works whether the emitter chose to put service first or region first because labels are unordered. Most consequentially: if a new dimension lands tomorrow — e.g. payment_method="upi" vs payment_method="card" — the Prometheus emitter adds one labelname and old queries still work, while the Graphite emitter has to rewrite every dotted name to insert the new component, and every existing alert / dashboard / function-pipeline that referenced the old path shape breaks until updated.

Why the slash-mangling is more than a cosmetic problem: it destroys the ability to reverse-map back from the stored metric to the operational meaning. Two months later, an SRE looking at app.payments.ap-south-1.requests._checkout cannot tell if the original endpoint was /checkout, _checkout (literal underscore), /check_out, or \checkout — every one of those would have been mangled to the same path. The label model preserves the original string in endpoint="/checkout", including the leading slash. This is one of dozens of small information-losses Graphite-shaped storage forced on operators, and it is invisible until the day a forensic question asks for the original.

The script also surfaces a second-order property worth naming. Whisper files were created lazily on first write — app.payments.ap-south-1.requests._checkout did not exist as a file until the first sample arrived, at which point Carbon allocated a new .wsp file. A misbehaving emitter that included a unique ID in the path created one new file per request. The Prometheus equivalent — adding request_id as a label — also creates one new series per request, but the failure mode is visible: prometheus_tsdb_head_series climbs in real time, the cardinality page in Grafana lights up, the on-call has a metric to alert on. The Graphite cliff was silent until inodes ran out; the Prometheus cliff is loud from the first hour. Same fundamental cost shape; very different operational ergonomics.

A subtle-but-real lesson is hiding in the script's endpoint.replace("/", "_") line. The dotted-name protocol does not fail when you give it a path containing a slash — it cheerfully creates a Whisper file with a literal slash in its name, which becomes a directory inside /opt/graphite/storage/whisper/..., which the wildcard glob at query time silently fails to traverse correctly, which means the metric exists on disk but cannot be queried. The failure is not at write time, not at query time, not at parse time — it is a silent data-loss bug embedded in the layer where the path separator is overloaded between application semantics and filesystem layout. Modern TSDBs have their own version of this — labels with newlines, Unicode in label values, label names colliding with PromQL keywords — but they all surface the failure at write or at scrape rather than burying it in the filesystem layer two indirections away from the operator.

How RRDTool and Graphite shaped what came after

The genealogy from RRDTool through Graphite to Prometheus is not a clean linear story; it is a graph of decisions that propagated forward as inheritances and decisions that were specifically rejected because the predecessors had paid the cost. Naming the inheritances and the rejections explicitly is what makes "why does PromQL have an irate()" or "why does Mimir's downsampling tier exist" make sense.

Inherited from RRDTool, still alive in 2026. The discipline of the explicit step — Prometheus's scrape interval is set per-job at deploy time, evaluation interval is set per recording rule, alert evaluation interval is centralised. The TSDB owns the cadence. The operator declares it once; the application fits it. The OpenMetrics specification (the format Prometheus emits) carries no notion of the application's preferred interval; it carries only metric_name{labels} value and lets the scraper decide when to read. Every team that tries to push the cadence down into the application — because their developers want to "control when their metric updates" — eventually rediscovers the RRDTool lesson: storage-side cadence is not a constraint, it is the substrate of consistency, and applications that fight it produce metrics that are technically present and operationally useless.

Inherited from RRDTool, abandoned but still echoing. Pre-allocation. Prometheus chunks are created lazily, sized dynamically, and compacted on a background goroutine — the opposite of RRDTool's create-time allocation. But the concept of a fixed-size head chunk that flushes on time or size boundaries is RRDTool's circular buffer reborn as a memory-mapped file. The chunk format is different; the operational shape — "samples land in the head, the head closes, the closed chunk goes to disk, the disk is the long-term tier" — is the same shape RRDTool was implementing in a single file. VictoriaMetrics's IndexDB and Mimir's chunk-on-S3 architecture both inherit this layered model. The names changed; the geometry did not.

Inherited from Graphite, fully alive. The function library shape. PromQL's rate, irate, increase, delta, histogram_quantile, predict_linear, aggregate_over_time, quantile_over_time, holt_winters — every one of these has a Graphite ancestor. The semantics are stricter (PromQL is a typed expression language; Graphite-web's URL functions were string composition) but the vocabulary is what an operator brought from Graphite-shaped operations. This was a deliberate design choice by the Prometheus team: lower the migration cost from Graphite by mirroring the function names, even when the underlying implementation had to be rebuilt from scratch. The ergonomic continuity is what made the first wave of Indian Graphite-to-Prometheus migrations (Flipkart 2017, Razorpay 2018, Cleartrip 2019) take quarters rather than years. Operators read the new query language and recognised most of the verbs.

Inherited from Graphite, deliberately rejected. Push-based ingestion, free-form names, no schema. Prometheus's pull model exists because Graphite's push model created operational nightmares (a runaway emitter could DDoS the carbon-cache port, or silently drop samples when the receiver's UDP buffer overflowed, or create millions of Whisper files before anyone noticed). The pull model puts the storage layer in control of who gets scraped; service discovery (Kubernetes, Consul, file-based SD) is the substrate of "what to monitor"; and the synthetic up series gives the operator a binary signal of target health that Graphite never had. The label tuple replaced the dotted name because dotted names baked hierarchy into the identifier; OpenMetrics replaced the line protocol because the line protocol carried no metadata; and the cardinality budget replaced silent inode exhaustion because the cliff is now a metric you can alert on. Every one of these choices was made with Graphite's specific failure modes in front of the designer.

The hybrids that emerged in the gap. Between Graphite's prime (2010-2015) and Prometheus's hegemony (2018-now) there was a period where teams ran both — Graphite for legacy applications still emitting dotted names, Prometheus for new services. The bridge tools from that era — graphite_exporter (Prometheus reads Graphite-format pushes and converts dotted names into label tuples via a regex-based mapping config), carbon-c-relay (high-throughput re-routing for Graphite samples), Whisper-to-Mimir migration scripts — are all responses to operational realities of the transition period. Hotstar ran a hybrid stack from 2017 to 2021; the graphite_exporter mapping config grew to 800 lines. Reading those mapping configs is one of the cleanest ways to understand what the dotted-name model could and could not express, because every mapping line is an operator translating an implicit Graphite hierarchy into an explicit Prometheus label tuple.

TSDB lineage from RRDTool to Prometheus and beyondA horizontal timeline from 1999 to 2026 showing the genealogy: RRDTool (1999) leads to Munin/Cacti/Ganglia (2003-2008) and to Graphite (2008). Graphite leads to StatsD (2011), to OpenTSDB (2010), to InfluxDB (2013), and to Prometheus (2012). Prometheus leads to VictoriaMetrics (2018), Cortex (2017), and Mimir (2022). Annotations on the arrows show what was inherited (function library, step discipline) and what was rejected (push protocol, dotted names).19992008201220182026RRDTool1999, OetikerMunin / Cacti2003-2008Graphite2008, DavisStatsD2011, EtsyOpenTSDB2010, StumbleUponPrometheus2012, SoundCloudInfluxDB2013, InfluxDataVictoriaMetrics2018Cortex2017, WeaveworksMimir2022, GrafanaM3DB2018, Uberstepfuncslabelsinheritedstep disciplinefunction libraryrejectedpush protocoldotted names
Illustrative — not measured data. The lineage from RRDTool (1999) through Graphite (2008) to Prometheus (2012) and the modern fleet (VictoriaMetrics, Mimir, M3DB). What was inherited: explicit-step cadence and the function library. What was rejected: push-based ingestion and dotted-name identifiers. Every box on the right is solving a problem some box on the left exposed.

There is one more inheritance worth naming because it surfaces in the most unexpected places — the dashboard mental model. Operators who learned monitoring on Cacti and Munin in the 2005-2010 period brought a specific cognitive habit into Graphite: every dashboard was a grid of identical-shape time-series panels, one per host or per service, arranged in a hierarchy that mirrored the dotted-name namespace. This habit survived the migration to Prometheus + Grafana intact, and most large Indian platform teams in 2026 still have dashboards organised by host or pod even though Prometheus's label tuple makes "by endpoint then by error_class" or "by customer_segment then by region" trivial to express. The new dashboard primitives exist; the operational instincts have not caught up. This is a soft cost rather than a technical one, but it is the kind of inheritance that survives three platform migrations because nobody wrote it down — it lives only in the muscle memory of the engineers who built the first dashboards, and they teach it to the next cohort by example.

Where the genealogy still bites in 2026

The point of telling this story is not nostalgia. It is to surface the bugs in modern stacks that exist because predecessors made decisions that were correct at the time and got inherited as bedrock. Four of these are worth naming explicitly because each one bites on-call engineers who don't know the history.

The "downsampling vs raw retention" decision in Mimir, Thanos, and VictoriaMetrics. All three modern long-term-storage tiers offer an option to downsample old data — e.g. keep raw samples for 15 days, then 5-minute averages for 90 days, then 1-hour averages for 1 year. The option exists because operators ask for it; the option is almost always wrong because the cost saving is small (compressed raw data is already 1.3 bytes per sample) and the forensic loss is exactly what RRDTool taught us to fear. Most platform teams that turn it on regret it the next time a forensic question lands. The default in 2026 should be raw all the way; downsampling is a footgun whose only win is when you genuinely cannot afford object-storage cost, which is rare for anyone outside the largest scales. The mental model that makes downsampling feel safe is the RRDTool-shaped one: "old data is summary anyway." It is not, in modern stacks, and the storage cost of preserving full resolution is small enough that the trade-off has flipped.

The graphite_exporter mapping config and the regex-rules trap. Indian teams running hybrid Graphite-Prometheus stacks during the 2017-2021 transition wrote regex mappings to translate app.payments.ap-south-1.requests./checkout into app_requests_total{service="payments",region="ap-south-1",endpoint="/checkout"}. The mapping configs grew to thousands of lines. They were never deleted after the migration completed. As of 2026, multiple teams (Hotstar, Cleartrip, InMobi) still run graphite_exporter in production for a small tail of legacy services, and the mapping config has become an undocumented schema that newer engineers have to reverse-engineer to understand metric names. The genealogical lesson: keeping a translation layer alive past its migration window is technical debt with a Graphite-shaped origin.

PromQL's irate() versus rate() — and why irate() exists. rate() averages over the entire query window; irate() uses only the last two samples and is more responsive. irate() exists because Graphite operators were used to nonNegativeDerivative on the most recent samples, and a Prometheus query that averaged across a 5-minute window felt sluggish to them. The Prometheus authors added irate() as the migration ergonomics function, and it has caused a particular class of dashboard bugs ever since — irate() over a sparse counter at 15-second scrape interval can return zero for an entire minute if the two most recent samples happen to be equal, which the operator sees as "the rate dropped to zero" when really the counter just hadn't incremented in the last 15 seconds. Most modern Prometheus advice says "use rate(), not irate()" — and the reason the advice has to be given at all is that irate() exists in the function library as a Graphite-migration concession.

The "every metric is a counter" reflex. Graphite did not have a strong distinction between counter, gauge, and histogram at the storage level — every Whisper file was just (timestamp, value) pairs. Operators picked up the habit of emitting a rate from the application, e.g. app.payments.requests_per_minute, and watching it directly. When those operators migrated to Prometheus, they kept the habit, and the result was Prometheus gauges that should have been counters. The cost: the gauge-of-rate emission shape lies across process restarts (the rate looks identical pre- and post-restart, the cumulative count of events during the restart window is silently lost), while the counter emission shape allows the TSDB's rate() function to detect the reset and report the gap honestly. The reflex is Graphite-shaped; the fix is recognising that the emission semantics matter, and that "counter for cumulative events, gauge for live readings" is a discipline the storage layer relies on. This is exactly the failure mode chapter 5 — "Wall: metrics without a TSDB" closed Part 1 with, and the Graphite genealogy is why the reflex is so persistent.

The pattern across all four: the modern TSDB has the right primitives, but operational instincts trained on the predecessor still live in the team. Recognising the genealogy is what lets a senior engineer say "this is a Graphite-era dashboard pattern, let's redesign it for the label tuple" rather than reinventing the same wheel inside Prometheus.

Common confusions

Going deeper

How Whisper writes a sample — the on-disk format that shaped a decade

A Whisper file (*.wsp) opens with a 16-byte header — aggregation_method (uint32: 1=AVERAGE, 2=SUM, 3=LAST, 4=MAX, 5=MIN), max_retention (uint32 seconds), xff (xFilesFactor, float, the fraction of populated samples required for an aggregation to be considered valid), and archive_count (uint32) — followed by archive_count archive-info structs of 12 bytes each, followed by the archive data itself laid out contiguously. Each archive is a sequence of (timestamp, value) pairs at the archive's resolution; the position of a sample within the archive is computed from its timestamp modulo the archive's slot count, and writes happen in-place at that slot. There is no append log, no chunk compaction, no compression — every sample is exactly 12 bytes (4 bytes timestamp + 8 bytes float64 value), and a 5-minute-resolution archive holding 7 days of samples occupies exactly 2016 × 12 = 24,192 bytes. The whole format was designed to be readable by mmap() plus pointer arithmetic, which made queries fast on the rotational disks of 2008 but punished SSD-era write patterns where the in-place update of a fixed slot triggered read-modify-write at the SSD page level.

The fixed slot layout is what makes Whisper queries cheap and forensics impossible. Reading "the value at timestamp T from archive A" is one mmap plus one pointer offset; reading "all samples from archive A" is a contiguous scan of 24 KB. Both are sub-millisecond. But there is no log of what was overwritten — the consolidation function is applied at write time, the older sample is replaced in place, and the original value is gone. This is the architectural ancestor of why modern object-storage-tiered TSDBs (Mimir, Thanos, Cortex) are so determined to make raw sample retention cheap: the lesson "in-place overwrite is forensically destructive" was learned on every Whisper file ever stored, and every modern stack is structured to make that mistake impossible to repeat. The whisper-fetch CLI exists, the format is documented, and reading a 2014-era Whisper file in 2026 is technically straightforward — the Python whisper library still works — but the data inside is averaged-of-averaged-of-averaged, and the original samples that produced those averages have been gone for over a decade.

The up series, scrape discovery, and what Graphite never had

Prometheus's pull model generates synthetic series for every scrape — up{job, instance} (1 if the scrape succeeded, 0 if it failed), scrape_duration_seconds, scrape_samples_scraped, scrape_samples_post_metric_relabeling. None of these have a Graphite equivalent, and the absence is structural: in a push model, the storage layer doesn't know whether a target should be sending samples, only what samples actually arrived. A target that crashed and stopped pushing simply stopped contributing data; the operator could see "the metric went flat" but had no per-target health signal. Carbon's metricsReceived counter aggregated across all senders; there was no up_per_sender. Modern push-model stacks (Datadog, OpenTelemetry-collector) fix this by having the application emit a synthetic heartbeat metric, but the responsibility lives in the application rather than in the storage layer. The pull model's up series is a structural property of the substrate, and it is the kind of "free meta-telemetry" that only becomes obvious when you've operated without it. Razorpay's 2018 migration playbook explicitly listed "you now have an up series for every target" as one of the reasons the on-call experience improved post-Prometheus.

Why InfluxDB took a different path — push, schema-free, SQL-like

The genealogy is not a single line. InfluxDB (Paul Dix, 2013) chose explicitly to keep the Graphite-style push model but replace the dotted name with an InfluxQL line-protocol that supported tags (named labels): weather,location=mumbai,sensor=A temperature=23.4 1714032000. Tags became indexed, fields became un-indexed, and the query language was deliberately SQL-shaped to reduce the cognitive cost for analytics teams migrating from relational databases. The trade-off: InfluxDB inherited Graphite's push-model fragility (silent drops under load, line-protocol edge cases around escaping, a different cardinality cliff that hit at lower series counts than Prometheus) but offered a query language familiar to non-SRE engineers. The 2017-2020 wave of Indian fintech platforms split between the two paths — Razorpay, Cred, and PhonePe went Prometheus; CRED's analytics team went InfluxDB; some smaller startups picked InfluxDB because their data team already wrote SQL and the existing Grafana plugin supported both. By 2023, the Prometheus + label model had won most of the SRE-side mind-share, but InfluxDB remains the dominant choice in IoT and industrial monitoring (where push-from-edge-device fits the workload better than pull-from-central-scraper). The genealogy is a graph, not a tree, and every branch made sensible trade-offs against the failure modes of its predecessor.

Reproduce this on your laptop

# Reproduce this on your laptop — Graphite vs Prometheus shape in 30 seconds.
python3 -m venv .venv && source .venv/bin/activate
pip install prometheus-client
python3 graphite_vs_prometheus.py
# Optional: install Graphite locally and emit real samples.
docker run -d --name graphite -p 2003:2003 -p 8080:80 graphiteapp/graphite-statsd
echo "app.payments.ap-south-1.requests._checkout 42 $(date +%s)" | nc -q 1 localhost 2003
curl 'http://localhost:8080/render?target=app.payments.ap-south-1.requests._checkout&format=json&from=-5min'

The first three lines run the comparison script and produce the same output shape shown above. The last three lines stand up a real Graphite container, push a sample via the carbon line protocol, and read it back via the render API — the same ergonomic loop Indian SREs ran in 2014, including the silent slash-mangling that the script demonstrates.

Where this leads next

This chapter opens Part 2 — Metrics Storage. Everything that follows is the modern continuation of the genealogy this chapter traced.

The sentence to carry into the next chapter: modern TSDBs are not new inventions; they are explicit responses to the operational failures of RRDTool and Graphite. Prometheus's pull model, label tuples, raw retention, and up series are each a specific scar from a specific Graphite-era outage that the Prometheus designers had personally run. Reading the modern stack as a graveyard of fixes — each primitive is here because something broke without it — makes the genealogy useful rather than merely historical. The next chapter dissects the Prometheus chunk and inverted-index implementation in detail; this chapter is the precondition that explains why those choices were made the way they were.

There is also a lesson hiding in the speed of the transition that is worth holding onto. Graphite reached operational dominance around 2013 and was largely supplanted by Prometheus around 2018 — five years from peak to legacy. The cycle is faster than most platform engineers expect. Mimir and VictoriaMetrics in 2026 are at roughly the position Prometheus was at in 2017 — proven at scale, growing share, but with operational quirks still being smoothed. The genealogical lesson is: the substrate you bet on today will be supplanted in five-to-seven years; the question is whether your dashboards, alerts, and operational habits will migrate cleanly. Teams that build their operations on the labels and functions (which are stable across the lineage) tend to migrate well; teams that build on the specific tool's quirks (like Graphite-era operators who wrote alerts on nonNegativeDerivative-of-sumSeries patterns that did not survive the move to PromQL) get stuck inside one generation. The architectural primitives outlive the products; betting on the primitives is the path that survives the next migration.

References

  1. Tobias Oetiker, "RRDtool" project documentation — the original tool, still maintained, still in production at thousands of sites. The man pages are the cleanest published account of the round-robin storage shape.
  2. Chris Davis, "Graphite — Scalable Realtime Graphing" (2008) — the Graphite project's documentation, including the Whisper format spec and the carbon line protocol.
  3. Etsy engineering blog, "Measure Anything, Measure Everything" (2011) — the StatsD origin essay, the canonical defence of the push-and-aggregate model that Graphite was at the centre of.
  4. Pelkonen et al., "Gorilla: A Fast, Scalable, In-Memory Time Series Database" (VLDB 2015) — the encoding paper. Reading it after this genealogy makes the trade-off explicit: variable-size compressed columns versus fixed-size pre-allocated arrays.
  5. Brian Brazil, Prometheus: Up & Running (O'Reilly, 2018) — chapters 1 and 5 cover the design choices that were made in explicit contradistinction to Graphite. Brazil was one of the early Prometheus core developers and the historical context is throughout.
  6. Cindy Sridharan, Distributed Systems Observability (O'Reilly, 2018) — chapter 4 places Graphite in the broader observability lineage.
  7. Wall: metrics without a time-series store are useless — chapter 5 of this curriculum, the thesis statement this chapter is the historical context for.
  8. Cardinality: the master variable — chapter 3, the budget that both Graphite and Prometheus enforce, with very different operational ergonomics.