Skip to content
About
Main content

Count your series

Measure your machineNeeds Python with prometheus_client

Problem

A metric is stored as one time per combination of label values, so its cost is the product of its labels' cardinalities, not their sum. A label with a few values is free; a label with thousands of values (a user id, a request id) multiplies every other label's combinations and every bucket.

Write an endpoint with the Python prometheus_client library: a request counter labelled by method, status and route, a histogram labelled by route, and a gauge. Count the series per metric and multiply the label cardinalities by hand. Then add one label with 10,000 values to the counter and measure the memory and the scrape size before and after.

The question. How many series does each metric expose, and what does one high-cardinality label do to the scrape and to memory?


Predict

Adding a user label (10,000 users) to the request counter: how many times larger does the scrape get?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    count-your-series.py60 linesDownload
    View the code
    count-your-series.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["prometheus_client"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Count your series.
    
    A metrics endpoint built with prometheus_client: a request counter labelled by
    method, status and route, a latency histogram labelled by route, and a gauge.
    Counts the series per metric against the product of label cardinalities, then
    adds one label with 10,000 values (a user id) to the request counter and
    measures the scrape size and the memory before and after.
    """
    import argparse, json, random, tracemalloc
    from prometheus_client import CollectorRegistry, Counter, Gauge, Histogram, generate_latest
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--users", type=int, default=10_000, help="Values of the added label.")
    p.add_argument("--requests", type=int, default=200_000, help="Requests to record.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    METHODS, STATUSES, ROUTES = ["GET", "POST"], ["200", "404", "500"], [f"/r{i}" for i in range(20)]
    def build(with_user):
        tracemalloc.start(); reg = CollectorRegistry()
        labels = ["method", "status", "route"] + (["user"] if with_user else [])
        req = Counter("http_requests", "Requests.", labels, registry=reg)
        lat = Histogram("http_latency_seconds", "Latency.", ["route"], registry=reg)
        Gauge("queue_length", "Queue length.", registry=reg).set(3)
        random.seed(0)
        for i in range(args.requests):
            m, s, r = random.choice(METHODS), random.choice(STATUSES), random.choice(ROUTES)
            req.labels(m, s, r, *( [f"u{random.randrange(args.users)}"] if with_user else [])).inc()
            lat.labels(r).observe(random.random())
        body = generate_latest(reg); mem = tracemalloc.get_traced_memory()[0]; tracemalloc.stop()
        series = {}
        for line in body.decode().splitlines():
            if line and not line.startswith("#"):
                name = line.split("{")[0].split(" ")[0]
                for suf in ("_total", "_created", "_bucket", "_count", "_sum"):
                    if name.endswith(suf): name = name[: -len(suf)]; break
                series[name] = series.get(name, 0) + 1
        return series, len(body), mem
    
    before, size0, mem0 = build(False)
    after, size1, mem1 = build(True)
    expected = {"http_requests": len(METHODS) * len(STATUSES) * len(ROUTES), "http_latency_seconds": len(ROUTES), "queue_length": 1}
    rows = [{"metric": m, "label_product": expected[m], "series_before": before.get(m, 0), "series_after": after.get(m, 0)} for m in expected]
    summary = {"size_before_kb": size0 / 1024, "size_after_kb": size1 / 1024, "mem_before_mb": mem0 / 2**20, "mem_after_mb": mem1 / 2**20,
               "size_x": size1 / size0, "mem_x": mem1 / mem0, "series_before": sum(before.values()), "series_after": sum(after.values()),
               "series_x": sum(after.values()) / sum(before.values())}
    for r in rows: print(f"{r['metric']:<22} label product {r['label_product']:>4}  exposed lines before {r['series_before']:>6}  after adding user {r['series_after']:>7}")
    print(f"scrape {summary['size_before_kb']:.0f} KB -> {summary['size_after_kb']:.0f} KB ({summary['size_x']:.0f}x); "
          f"memory {summary['mem_before_mb']:.1f} MB -> {summary['mem_after_mb']:.1f} MB ({summary['mem_x']:.0f}x)")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"summary": summary, "tables": {"metrics": rows}}, f)
    
  3. 3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.

    Terminal
    uv run count-your-series.py
    uv run count-your-series.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--users10,000 Values of the added label.
--requests200,000 Requests to record.

Our run

Exposed lines per metric

Exposed lines per metric
MetricLabel productBeforeAfter adding user
http_requests120240368K
http_latency_seconds20360360
queue_length111
Reference runApple M2 · 8 cores · 8 GB
Output · uv run count-your-series.py
http_requests          label product  120  exposed lines before    240  after adding user  368266
http_latency_seconds   label product   20  exposed lines before    360  after adding user     360
queue_length           label product    1  exposed lines before      1  after adding user       1
scrape 38 KB -> 31302 KB (815x); memory 0.4 MB -> 140.0 MB (342x)
macOS 26.6.2 · Python 3.13.3 · prometheus_client 0.26.0 · run 2026-10-20

What we found

Before, the request counter exposed 240 lines (its label product of 120, times a total and a created timestamp each). With the user label it exposed 368K: every combination actually seen, which with 200,000 recorded requests was most of them. The scrape grew from 38.4 KB to 31.3K KB (815×) and memory from 0.409 MB to 140 MB (342×), in proportion to the series, not to the one label added.


Read it

Series count is the unit of cost for a metrics system: memory in the client, bytes per scrape, and storage and index size in the server, all multiplied by retention. A high-cardinality value belongs in a log or a trace, where its cost is per event, not per combination.