Choosing Between Counter, Gauge, Histogram, and Summary

Picking the wrong Prometheus metric type produces graphs that lie: counters read as gauges drop to zero on every deploy, and summaries chosen for fleet-wide objectives return per-process nonsense that no query can repair. This walkthrough is for backend engineers and SREs instrumenting a Python service who need to settle the type question once, at design time, before the metric reaches a dashboard. It sits under metric types and cardinality control and is part of the broader Python metrics and instrumentation reference; once the type is settled, the second half of the design decision is keeping the label dimensions bounded, which is covered in controlling label cardinality in Prometheus.

Metric type decision tree Three questions asked in order. Does the value only ever rise: yes gives a Counter, always read through rate. Is it one current value: yes gives a Gauge, a snapshot of right now. Must the quantiles merge across replicas: yes gives a Histogram whose buckets sum then interpolate, no gives a Summary, valid only for a single process exporting sum and count. Does the value only ever rise? (falling only on restart) yes Counter always read through rate() no Is it one current value? (moving up and down) yes Gauge a snapshot of right now no Must the quantiles merge across replicas? yes Histogram buckets sum, then interpolate no Summary one process; _sum / _count
Three questions asked in order: each "yes" settles the type, each "no" moves down to the next question.

Prerequisites

pip install "prometheus-client>=0.20.0,<1.0.0"

Python 3.10 or newer and a Prometheus server (>=2.50,<3.0) scraping the exposition endpoint are assumed. No environment variables are required for these examples beyond an open exposition port, which start_http_server provides; the endpoint mechanics themselves are covered in exposing custom metrics with prometheus_client.

The four types in one sentence each

A Counter is a value that only goes up and resets to zero on process restart; it answers how many events have occurred and is meaningful only as a rate. A Gauge is a value that can move in either direction and represents a snapshot of current state. A Histogram observes values into fixed cumulative buckets and exports the raw bucket counts so the server can derive quantiles at query time. A Summary computes a distribution summary inside the client process and exports the result directly.

The four split cleanly into two pairs. Counters and gauges describe a single number and differ only in whether that number can fall. Histograms and summaries describe a distribution of many numbers and differ in where the statistics are computed — on the server, from raw buckets, or in the process, before export. Almost every wrong choice in production comes from confusing one member of a pair with the other, so the questions below separate the pairs first and then resolve within each pair.

The four instruments as a two-by-two matrix Rows split one number from a whole distribution; columns split values the query still has to compute from values that are already final on export. Counter exports app_errors_total and is read with rate. Gauge exports task_queue_depth and is read directly. Histogram exports bucket, sum and count series and is read with histogram_quantile. Summary exports only sum and count in Python, giving an average and no quantiles. Computed by the query the export is raw material Computed before export the value is already final ONE NUMBER A WHOLE DISTRIBUTION Counter how many events, ever EXPOSES app_errors_total read it with rate() or increase() Gauge the value right now EXPOSES task_queue_depth read it directly; avg, max, min Histogram counts per bucket boundary EXPOSES _bucket _sum _count histogram_quantile() at query time Summary statistics fixed in the process EXPOSES (PYTHON) _sum _count an average only, no quantiles
The pairs split by row; within each row the column decides whether the query still has work to do or the process already did it.

Implementation

The choice reduces to three questions answered in order, mirroring the decision tree above.

Step 1 — Does the value only ever increase, resetting only on restart? If so, it is a Counter. Counts of events — requests, errors, bytes processed, retries — are the canonical case. Never read a counter's raw value in a query; wrap it in rate() so process restarts are handled as resets rather than data loss.

from prometheus_client import Counter

# The _total suffix is the exposition convention; the client normalises it either way.
ERRORS = Counter("app_errors_total", "Errors raised", labelnames=("kind",))
ERRORS.labels(kind="timeout").inc()      # monotonic; always query through rate()

A common trap at this step is reaching for a Gauge because the number "goes up over time and I want the total." If the underlying events are discrete and you care about the rate or the increase over a window, it is still a Counter even though the displayed total grows. The test is not whether the displayed number increases but whether the instrument can ever decrease for a reason other than a restart. Error totals, requests served, and bytes written never legitimately decrease, so they are counters regardless of how you plan to visualise them. Keep the kind label drawn from a fixed, enumerable set — a counter is exactly as expensive as the product of its label values.

Step 2 — Is it a current value that moves both up and down? That is a Gauge: queue depth, in-flight requests, connection pool size, temperature, memory in use. Use set, inc, and dec, or track_inprogress() as a context manager for concurrency counts. For a value that is expensive to poll, register a callback with set_function so the value is computed at scrape time instead of on a hot path.

from prometheus_client import Gauge

QUEUE_DEPTH = Gauge("task_queue_depth", "Pending tasks")
QUEUE_DEPTH.set(get_queue_length())          # snapshot; can rise and fall

IN_FLIGHT = Gauge("http_requests_in_flight", "Requests currently being served")
with IN_FLIGHT.track_inprogress():           # inc on entry, dec on exit, exception-safe
    serve_request()

Gauges carry a subtlety in aggregation: because a gauge is a point-in-time value, summing it across replicas is only meaningful when the quantity is genuinely additive, such as total memory used by a fleet. Averaging or taking the maximum is correct for quantities like utilisation percentages. Choosing the wrong aggregation function over a gauge produces a number that is valid PromQL but semantically meaningless, so decide at design time how the gauge will be combined across instances. The same decision resurfaces inside a single service when it runs under Gunicorn or uWSGI: in multiprocess mode a gauge must declare multiprocess_mode so the collector knows whether to sum, average, or take the live minimum or maximum across worker processes.

How a restart reads on a counter and on a gauge On the left the cumulative counter climbs and snaps back to zero at a deploy, while the rate of the same series below runs smoothly through the reset. On the right the gauge simply rises and falls as a snapshot, so it has no reset to read and must never be wrapped in rate; combining it across replicas means summing only when the quantity is additive, averaging or taking a maximum otherwise, and declaring multiprocess_mode under a forking server. Counter CUMULATIVE VALUE deploy: reset to zero rate() OF THAT COUNTER rate() reads the drop as a restart Gauge CURRENT VALUE COMBINING ACROSS REPLICAS sum: only when the quantity is additive avg or max: for a utilisation figure multiprocess_mode: under Gunicorn no reset semantics, so no rate()
A restart is information on a counter and noise on a gauge, which is why one is always queried through rate() and the other never is.

Step 3 — Is it a distribution of observed values you want quantiles or a spread of? Then it is a Histogram or a Summary, and the deciding sub-question is aggregation. If you need quantiles across many replicas — almost every latency objective — choose a Histogram, because its buckets sum across instances and histogram_quantile() runs on the merged result.

from prometheus_client import Histogram

LATENCY = Histogram(
    "request_duration_seconds", "Request latency",
    # Explicit edges that straddle the 250 ms objective; +Inf is appended for you.
    buckets=(0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5),
)
with LATENCY.time():                          # observes elapsed seconds on exit
    serve_request()
from prometheus_client import Summary

# Only for a single-instance distribution you will never aggregate.
GC_PAUSE = Summary("gc_pause_seconds", "GC pause duration")
with GC_PAUSE.time():
    run_gc_cycle()

A worked counterexample for the pairs above: for a cache hit ratio, resist a single Gauge holding the percentage. Expose two Counters — hits and total lookups — and divide their rates at query time with rate(cache_hits_total[5m]) / rate(cache_lookups_total[5m]). This preserves the correct windowing and aggregation that a precomputed gauge throws away, and it lets one instrumentation decision serve a five-minute panel and a thirty-day report.

Why the histogram usually wins

The histogram-versus-summary decision deserves its own scrutiny because it is the most consequential and the most often gotten wrong. A histogram records nothing more than a count per bucket, so the work it does at observe time is a cheap increment on a handful of counters. All the statistical effort — interpolating a quantile between two boundaries — happens later on the server, over whatever set of series the query selects, which is exactly why buckets can be summed across replicas before histogram_quantile() runs.

A summary instead computes its statistics inside the client, producing values tied irrevocably to one process. Precomputed quantiles cannot be averaged or summed: the p99 of two processes is not the average of their individual p99s, and PromQL has no way to recover the true combined quantile from the published ones. For any objective measured across more than one replica, that property alone forces the histogram.

There is a second, Python-specific reason the summary is nearly always the wrong answer. Unlike the Go client, prometheus_client implements no streaming quantile estimator at all: a Summary exports only _sum and _count, so the single statistic you can derive from it is an average. If you want any quantile from a Python process — even from one process you will never aggregate — you need a Histogram. That leaves the Summary useful only where an average and a rate of observations genuinely answer the question, such as a background daemon's mean work-item duration.

Why buckets merge across replicas and precomputed quantiles do not Above, each replica exports raw bucket counts; summing them by the le label produces one merged distribution that histogram_quantile interpolates into a single fleet-wide p99. Below, each replica exports a finished p99; averaging or summing those numbers is invalid, because the p99 of the combined population is not the average of the per-process p99s, and PromQL cannot recover it. In Python the case is stronger still: a Summary exports only sum and count, so no quantile exists to combine. Histogram: raw buckets, summed then interpolated replica 1 replica 2 replica 3 sum by (le) one merged distribution histogram_quantile() one fleet-wide p99 Summary: finished numbers, nothing left to merge replica 1 p99 = 120 ms replica 2 p99 = 310 ms replica 3 p99 = 95 ms avg / sum not a valid quantile p99(A+B) is not avg(p99 A, p99 B) and PromQL cannot recover the true one in Python there is no quantile series to begin with, only _sum and _count
Buckets are raw material and therefore additive; a published quantile is a finished answer, and finished answers cannot be recombined.

A practical note on histogram evolution: bucket boundaries are part of the data, not just the query. If you redeploy with new boundaries, samples recorded under the old boundaries keep them, and a quantile spanning the change blends two resolutions. Plan bucket layouts to outlast the dashboards that consume them, keep the count modest — each boundary is another stored series per label combination — and place an edge on every threshold you alert against so the interpolation has resolution exactly where it matters. The default bucket set spans 5 ms to 10 s and is a reasonable starting point for HTTP handlers, but it is rarely the right set for a queue consumer or a batch step.

The same reasoning transfers directly to the OpenTelemetry instrument names: Counter and UpDownCounter cover the monotonic and bidirectional single values, ObservableGauge covers the callback-polled snapshot, and Histogram covers the distribution. There is no summary instrument in the OTel data model at all, which is the standards body making the same judgement this section reaches — see recording counters and histograms with OpenTelemetry for the API shape, and OpenTelemetry vs Prometheus for Python metrics if you have not yet settled which pipeline records them.

Configuration options

Type Direction Query pattern Aggregates across replicas Key constructor argument
Counter Monotonic up rate(), increase() Yes — sum the rates labelnames, bounded value sets only
Gauge Up and down raw value, avg, max Only if the quantity is additive multiprocess_mode under a forking server
Histogram Distribution histogram_quantile() over _bucket Yes — sum buckets, then interpolate buckets, with an edge on every threshold
Summary Distribution _sum / _count for an average Sum and count only, never an average of averages none — no quantile support in Python

Two further knobs apply to all four. namespace and subsystem prefix the metric name at construction time, which is the cleanest way to keep one service's series from colliding with another's in a shared Prometheus. Setting PROMETHEUS_DISABLE_CREATED_SERIES=true suppresses the _created timestamp series that counters, histograms, and summaries emit by default, cutting the stored series count for those instruments without changing any value you actually query.

Verification

Scrape the exposition endpoint and confirm each instrument renders with its declared # TYPE line and the expected series shape: counters and gauges as a single sample, histograms as _bucket plus _sum and _count, summaries as _sum and _count only. If a series shape does not match the type you intended, the instrument is wrong in code — no dashboard change will fix it.

curl -s localhost:8000/metrics | grep -E "app_errors|task_queue_depth|request_duration|gc_pause"

Expected Output: the four types appear distinctly in the exposition text.

# TYPE app_errors_total counter
app_errors_total{kind="timeout"} 1.0
# TYPE task_queue_depth gauge
task_queue_depth 7.0
# TYPE request_duration_seconds histogram
request_duration_seconds_bucket{le="0.25"} 1.0
request_duration_seconds_sum 0.031
request_duration_seconds_count 1.0
# TYPE gc_pause_seconds summary
gc_pause_seconds_sum 0.004
gc_pause_seconds_count 1.0

Note what is absent: the summary emits no quantile series, confirming that a p99 panel built on it would have nothing to read. The histogram, by contrast, exposes one _bucket series per boundary, and histogram_quantile(0.99, sum by (le) (rate(request_duration_seconds_bucket[5m]))) returns a fleet-wide answer from them. Run that query against a single replica and then against the whole job; both should return a plausible latency, and the fleet number should sit between the per-replica extremes.

Common mistakes

  • Error signature: a counter panel sawtooths to zero on every deployment. Root cause: the query reads the raw cumulative value, which resets when the process restarts. Remediation: wrap counters in rate() or increase(), both of which detect the reset and treat it as a restart rather than a negative jump.

  • Error signature: a p99 panel spanning replicas shows a suspiciously smooth line, a per-instance value, or nothing at all. Root cause: the instrument is a Summary, whose statistics are computed per process and — in Python — do not include quantiles to begin with. Remediation: switch to a Histogram and compute the quantile from summed buckets with histogram_quantile().

  • Error signature: a ratio panel cannot be re-windowed or broken down by instance without re-instrumenting. Root cause: the division happened in the application and was stored as one Gauge, discarding the numerator and denominator. Remediation: expose the two underlying Counters and divide their rates in the query, which keeps every window and grouping available afterwards.

  • Error signature: histogram_quantile() returns a value pinned exactly to a bucket edge, or jumps between two fixed numbers as load changes. Root cause: the objective threshold falls in a wide gap between boundaries, so the interpolation has no resolution where it matters. Remediation: add explicit edges around the threshold, redeploy, and be aware that only samples recorded after the change carry the finer resolution.

A quick reference for the common cases

To make the decision automatic for the measurements that appear in almost every service, fix these defaults in mind. Request and error totals are Counters, queried with rate(). In-flight requests, queue depth, and connection-pool size are Gauges. Request latency and response size are Histograms with buckets aligned to the objective — the pattern used throughout instrumenting Flask with Prometheus metrics. Cache hit ratio is two Counters divided at query time, never a Gauge. A mean garbage-collection pause on a single daemon is the rare defensible Summary. Anchoring on these defaults removes most of the per-metric deliberation and leaves only the genuinely novel measurements to reason about from the three questions above.

When a measurement does not fit any default, walk the questions in order — only increases, moves both ways, or a distribution — and let the answer pick the type before you think about labels. Then apply the cardinality lens: if the natural label is unbounded, keep that dimension in traces or logs and instrument the bounded view instead. The type decision and the label decision together produce telemetry that is cheap to store and correct to query, and they are far easier to get right at design time than to unwind after a dashboard depends on them.

Frequently Asked Questions

When should I use a Counter instead of a Gauge?

Use a Counter for cumulative event totals that only increase, such as requests served or errors raised, and always query them with rate(). Use a Gauge for values that move up and down, such as queue depth or memory in use.

Is a Histogram always better than a Summary?

For fleet-wide latency SLOs, yes, because histogram buckets aggregate across replicas and let you compute quantiles at query time. A Summary is only preferable when you need a precise quantile from a single process and will never aggregate it.

Does the Python client give me quantiles from a Summary?

No. Unlike the Go client, prometheus_client exposes only the _sum and _count series for a Summary, so the only statistic you can derive is an average. If you need any quantile at all in Python, you need a Histogram.

Can I change histogram buckets after deployment?

You can change the bucket definition in code and redeploy, but historical samples keep their original buckets. Quantiles you derive at query time will only be as precise as the buckets that were active when the data was recorded.

What metric type fits a cache hit ratio?

Use two Counters, one for hits and one for total lookups, then divide their rates at query time. Computing the ratio as a single Gauge in the application loses the ability to window and aggregate it correctly.