Labels and Series
Overview
Your notebook can tell you how many coffees you sold, but it can't tell you the exact drinks.
Say you want to know how much milk to order, and lattes are where most of it goes. How many lattes did you sell today? The notebook has no idea: every sale went into the same count.
In this lesson, you'll learn how labels keep a bit of detail with each sample, without going back to the pile of order tickets.
Objectives:
- Split one metric into categories with labels
- Explain what a time series is and what identifies one
- Count how many series a set of labels creates
- Tell a safe label from one that never stops creating series
Labels
Let's start with a question again.
Suppose you want to make sure you never run out of milk. Lattes use most of it, so what you need to know is:
How many lattes do we sell in a day?
Here is the data you have:
| Time | coffees_sold_total |
|---|---|
| 09:00 | 3 |
| 10:00 | 32 |
| 11:00 | 35 |
35 coffees sold by 11:00. How many of those were lattes?
You can't tell. 🤷
Every order was folded into the same count, and the drink on the ticket went with it.
Most of the time, the total is all you need. But some questions need a finer answer: not how many coffees, but how many of each kind.
Metrics don't keep the details of individual events, that's what makes them cheap. What they can keep is a category the event belongs to. In our case, every sale (event) is a latte, an espresso, a cappuccino, or an americano. The sale still disappears into a count, what survives is which count it went into.
💡 This is a good trade.
A category has a handful of values, so it turns one count into a handful of counts: still cheap to keep, and they still add up to the total.
Labels are how a metric records a category.
A label is a key-value pair attached to a metric. In our case:
drinkis the label namelatteis the label value
Now you can keep one metric name for all four drinks:
| Time | Metric name | Label | Value |
|---|---|---|---|
| 11:00 | coffees_sold_total | drink="latte" | 15 |
| 11:00 | coffees_sold_total | drink="espresso" | 10 |
| 11:00 | coffees_sold_total | drink="cappuccino" | 7 |
| 11:00 | coffees_sold_total | drink="americano" | 3 |
Only the label value differs between the rows, and the four values still add up to the total:
15 + 10 + 7 + 3 = 35
Why not lattes_sold_total?
Four separate metrics (lattes_sold_total, espressos_sold_total, and so on) would also work,
but nothing would tie them together.
Getting the total back means knowing every name and adding them up by hand.
Labels keep the drinks apart and together: you can look at one, or add them all up.
A sample can carry more than one label (or none at all).
Say the shop also records the roast of the beans, light or dark: that's a second label, roast, on every sale.
All the labels on one sample form its label set.
A labeled sample has four parts:
- the three from the previous lesson (timestamp, metric name, and value),
- the label set.
See It in the Cafe
Start the playground for this lesson (if you haven't already).
It's the same cafe as before, with one difference: the notebook now keeps track of which drink each sale was.
Sell two lattes, one espresso, and one cappuccino.
Click Observe now.
The new entry shows the total (Coffees sold: 4) and, right below it, the breakdown: two lattes, one espresso, one cappuccino.
Time Series
If you had to analyze sales by drink in your notebook, you'd probably draw something like this:
Why is this the shape you'd draw without thinking?
Because it lines the numbers up with the questions you'll ask:
- How was the shop doing at noon? Read across the 12:00 row.
- How did lattes do over the day? Read down the latte column.
- How many cappuccinos were sold by noon? Read the cell at the intersection of the 12:00 row and the cappuccino column.
- How many coffees altogether? Add the columns up row by row.
Each question traces a shape on the table:
As it turns out, this is more than an easy-to-read format for humans. Monitoring systems store metrics in almost exactly this shape, and for the same reason: it is optimized for the questions you'll ask.
Let's draw the same table a bit differently:
Each column of the table became its own series of values, identified by the metric name and label set. Each value also has a timestamp of when the sample was recorded. Same table, same data, just sliced into one column per drink.
A timestamped series of values like this is called a time series.
💡 Two samples belong to the same time series when they have the same metric name and the same label set.
In our cafe, coffees_sold_total has a single label name: drink, with four possible values.
As a result, the metric is stored as four time series, one per drink.
This representation might be less readable than the table above, but it makes querying (reading and processing) metrics cheap.
Let's take a look at another example to understand why.
Remember the roast label from the previous unit? Let's track it alongside the drink.
Customers can choose either roast for any drink.
If you wanted to draw the same table as earlier, you'd draw something like this:
It's still manageable, but it's admittedly less readable than the drink-only table. It also takes a bit more work if you are interested in only one or the other label at a time.
With the right structure, however, that's no problem for a machine:
This time each subcolumn became its own series, and its headers carried over as two labels. Four drinks times two roasts: eight series in total.
Look at the two latte series.
They share the same drink, but one has roast="light" and the other roast="dark".
That single difference is enough to make them separate series.
A series is identified by its complete set of labels, not just one of them. So a light-roast latte sale always lands in the light-roast latte series, never in the dark-roast one.
💡 Notice what the new label did: latte used to be one series, now it's two.
Labels don't just describe samples, they decide how many series exist.
Why is this an ideal storage model?
Consider the kinds of questions you want to answer based on the data:
- How many lattes were made of light roast? Find the one series with
drink="latte"androast="light", and read its latest value. - How many drinks were made of dark roast? Find each series where
roast="dark", and add up their latest values. - How many lattes were made by noon? Take both latte series, get their values at 12:00, and add them up.
Think about how you'd answer them in the notebook as you'd naturally keep it: every observation on the next free line, all drinks and roasts mixed together.
To answer "how many dark roast drinks?" you read every line and pick out the ones that matter. The thicker the notebook, the longer it takes, and most of what you read you throw away.
A monitoring system keeps the same data differently: as time series, one per label set, plus an "index" that lists, for every label value, the series that carry it.
Now each question takes the same two steps:
- Find the series. Look up what you asked for (
roast="dark", say) in the index. Only labels are looked at here, no sample values. - Read their samples. Each series keeps its samples together, in time order, so reading one is a single sweep from first to last. Series that didn't match are never touched.
One lookup and a few series reads, however thick the notebook gets.
💡 "Index" is a generic term here: a lookup that tells you where to find things, like the one at the back of a book.
In database terms
The flat list is a table with one row per sample: time, metric name, labels, value.
In a relational database without an index, WHERE roast = 'dark' is a full scan of that table.
Add an index on the labels and finding the rows gets quick, but the rows of one series are still scattered across the table, so reading a series means jumping around instead of one sweep.
A columnar database stores each attribute as its own column, so the roast column can be scanned cheaply to find the matching rows.
Reading a series still isn't a sweep, though: inside the value column, one series' samples are interleaved with every other series' from the same moment.
Both fix it the same way a monitoring system does: give each series an identifier, sort the samples by series and time, and keep a separate lookup from label values to identifiers.
ClickHouse's TimeSeries engine, built for Prometheus data, is exactly that: a samples table ordered by (id, timestamp) and a tags table mapping metric name and labels to id.
A time series database is what you get when those two decisions are built in instead of designed by hand.
Selecting a few series and reading only their samples is what makes querying metrics cheap.
However, there is a catch: every distinct combination of metric name and label set is its own series. How many series do your labels create?
Cardinality
Cardinality is the number of distinct time series for a particular metric. If you know the possible values of each label, you can work out the ceiling: multiply the number of possible values of each label.
You've already seen most of it in this lesson: one series with no labels, four with drink, eight with drink and roast.
Add a third label, size, with three values, and the eight become twenty-four:
| Label set | New label's values | Series |
|---|---|---|
| (empty) | - | 1 |
drink | latte, espresso, cappuccino, americano | 4 |
drink, roast | dark, light | 8 |
drink, roast, size | small, medium, large | 24 |
Twenty-four is the ceiling, not the headcount. The multiplication counts every combination of label values, but a series only comes into existence when the first sample with that exact label set is recorded. Combinations that never happen never become series.
Say espresso comes in small only, lattes in medium and large, and cappuccino in medium only. Only americano gets all three sizes. Ten of the twenty-four slots stay empty:
Label values often travel together like this, so the real count lands below the product. The product is still the number to plan with: it's the worst case, and only a menu change moves it.
So why does cardinality matter?
The example above shows how quickly it grows: every new label multiplies the number of possible series. The time series database inside a monitoring system can handle thousands of series without breaking a sweat, but there's a common pitfall, one that every label in this lesson has quietly avoided so far.
Remember what makes querying metrics cheap: every question starts by selecting the series it needs, and only their data gets loaded. "How many lattes?" picks the 4 latte series (2 roasts x 2 sizes) out of the 14 and never touches the rest.
Look at the labels you've used. All their values can be read off the menu:
| Label | Possible values | Count |
|---|---|---|
drink | latte, espresso, cappuccino, americano | 4 |
roast | dark, light | 2 |
size | small, medium, large | 3 |
Value sets like these are bounded: you can list every value in advance. That's what makes the multiplication trustworthy. It gives you a ceiling you know ahead of time, and the ceiling only moves when the menu does.
Now imagine recording the order number as a label. Every order gets a new number, so every order creates a new series:
The value set of order_id is unbounded: normal operation keeps producing values nobody has seen before.
There's no ceiling to calculate, the count just grows:
a quiet day adds hundreds of series, a busy month hundreds of thousands.
And the cheap lookup is gone:
drink="latte" now matches one series for every latte ever ordered.
Selecting a few series and skipping the rest was the whole trick, and high cardinality breaks exactly that.
Putting a per-event identifier in a label is a cardinality trap: series growth follows the events instead of a fixed set of categories.
Labels are for categories that group many events, not for details that identify one.
💡 For now, it's enough to know what cardinality is and to recognize the trap.
Later in the course, you'll learn how to choose safe labels when instrumenting an application, and how the monitoring system protects itself when an unsafe one slips through anyway.
From Shop to Service
Back to the web service. The steps are the same.
| Coffee shop | Web service | |
|---|---|---|
| The question | Which drinks sell? | Which pages get traffic, and how many requests fail? |
| The category | Drink | Route and status code |
In the previous lesson, you counted every line of the access log to get http_requests_total.
A minute later the log has grown to five lines. Take a closer look at them:
10.0.0.5 - - [26/Aug/2026:09:00:01 +0000] "GET /checkout HTTP/1.1" 200 512
10.0.0.8 - - [26/Aug/2026:09:00:03 +0000] "GET /checkout HTTP/1.1" 200 512
10.0.0.5 - - [26/Aug/2026:09:00:04 +0000] "POST /checkout HTTP/1.1" 500 87
10.0.0.9 - - [26/Aug/2026:09:00:08 +0000] "GET /menu HTTP/1.1" 200 2048
10.0.0.5 - - [26/Aug/2026:09:00:11 +0000] "POST /checkout HTTP/1.1" 500 87
Each line has the route (/checkout, /menu) and the status code (200, 500).
Those are the drink on the ticket: a category every request belongs to.
Count the lines by category instead of all at once, and you get labeled samples.
Same trick as wc -l, but counting only the matching lines:
grep -c '/checkout HTTP/1.1" 200' /var/log/nginx/access.log
| Time | Metric name | Labels | Value |
|---|---|---|---|
| 09:01 | http_requests_total | route="/checkout", status="200" | 2 |
| 09:01 | http_requests_total | route="/checkout", status="500" | 2 |
| 09:01 | http_requests_total | route="/menu", status="200" | 1 |
Three series under one metric name.
Add them up and you get 5, the same number wc -l gives you for this log.
Look at one, and you can tell that checkout is failing half the time.
The trap is here too.
The access log also records who sent the request (the client IP), and a real service attaches a request ID to every line.
Those identify one request, so they don't belong in a label:
client_ip and request_id would create a series per visitor or per request.
Route and status are bounded: a service has a fixed set of routes, and HTTP has a few dozen status codes.
💡 One catch with routes: the label needs the route pattern, not the actual URL.
/orders/1001, /orders/1002, and so on are one route (/orders/{id}),
but as raw URLs they're an unbounded value set with an order ID hiding inside.
You'll learn how to deal with this later in the course.
Side by side:
| Coffee shop | Web service | |
|---|---|---|
| The category | drink | route, status |
| A labeled sample | coffees_sold_total with drink="latte" | http_requests_total with route="/checkout", status="200" |
| Number of series | One per drink | One per route and status combination |
| The trap | order_id, customer_id | request_id, client_ip, raw URLs |
Summary
In this lesson, you learned how labels add detail to metrics without turning them back into a pile of events.
Key takeaways:
- A label is a key-value pair attached to a metric. It keeps a category of events (which drink), not the events themselves.
- A time series is a sequence of samples with the same metric name and label set. Change either one and you have a different series.
- Cardinality is the number of series. Every new label multiplies the ceiling: 4 drinks x 2 roasts x 3 sizes = up to 24 series.
- A label with unbounded values (
order_id,customer_id) creates series forever. Labels are for categories, not identifiers. - The same steps work for a web service: the drink becomes the route and status code, and
coffees_sold_totalwithdrink="latte"becomeshttp_requests_totalwithroute="/checkout",status="200".
Metric name and labels say which series a sample belongs to. They say nothing about how the values behave: whether they only go up, like coffees sold, or go up and down, like the number of customers in the shop.
The next lesson gives those behaviors names: metric types.
- Previous lesson
- Metric Samples
- Next lesson
- Metric Types