mrkeyoor.com_
Thu 06 Aug 02:43 UTC
PyPIInfraupdated 06 Aug 2026

prometheus-client

prometheus-client is the official Python library for making an application scrapeable by Prometheus. You declare metrics as module-level objects (Counter, Gauge, Histogram, Summary, Info, Enum), call methods on them from your code, and expose an HTTP endpoint that renders the current values in the Prometheus text format. Everything is held in process memory and there is no push, no agent, and no buffering: a scrape reads whatever the counters say at that instant. The library also ships the plumbing around that idea, including a WSGI app and an ASGI app you can mount into Flask or FastAPI, a Pushgateway client for batch jobs, a textfile writer for the node exporter, and default collectors that report process CPU, memory, file descriptors, Python version, and garbage collection stats.

Verdict

If your monitoring stack is Prometheus, this is the library and there is no real second choice. Budget an afternoon for multiprocess mode if you serve traffic behind gunicorn, and be disciplined about label cardinality, because the library will happily let you allocate a million time series.

API stability5/5Still numbered 0.x after eleven years, but the Counter, Gauge, Histogram, and Summary APIs have not changed shape in that time and releases are additive. Treat the 0.x as a versioning habit rather than a warning; the breakage risk in practice is very low.
Docs4/5prometheus.github.io/client_python has a page per metric type with parameter tables, plus a multiprocess page that lists every limitation up front instead of hiding them. What is missing is guidance on the things that actually go wrong in production: cardinality budgets, testing patterns, and how to structure metric declarations in a large codebase.
Maintenance4/50.26.0 released 24 July 2026 with the repo pushed the same day, and it sits under the Prometheus organisation rather than one person. The queue tells the other half of the story: 104 open issues (143 counting PRs) and a release cadence measured in months, so a niche bug can sit for a long time.
Ecosystem5/5About 42M weekly downloads and the assumed default for Python instrumentation, with framework wrappers (django-prometheus, prometheus-fastapi-instrumentator, starlette-exporter) built on top of it and every Prometheus tutorial written against it. The exposition format is also read by Grafana Agent, VictoriaMetrics, and OpenTelemetry collectors, so the endpoint is portable.

Use it if

  • You run Prometheus or anything that speaks its scrape protocol and want your Python service to expose /metrics with a few lines rather than a sidecar
  • You want latency histograms you can turn into p95 and p99 with histogram_quantile in PromQL, which the Histogram type plus a bucket list gives you directly
  • You are instrumenting a long-lived service (web app, worker, daemon) where in-memory counters survive long enough to be scraped
  • You want the standard process and Python runtime metrics for free: importing the package registers process, platform, and GC collectors into the default registry
  • You need a batch job to report something: write_to_textfile for the node exporter, or push_to_gateway when there is no file system to share
Skip it if

Setup reality

pip install prometheus-client needs Python 3.9 or newer and has no required dependencies, with optional extras for twisted, aiohttp, and django. A single-process service is genuinely two lines: start_http_server(8000) opens a daemon-thread HTTP server, and your metrics appear. The complexity all lives in three places. First, metrics must be declared once at import time; declaring one inside a request handler raises a duplicate registration error on the second call. Second, the default REGISTRY is global, so tests that import your modules twice, or run in parallel, hit the same duplicate error unless you pass registry=None or a fresh CollectorRegistry. Third, multiprocess deployments are a different product: you set PROMETHEUS_MULTIPROC_DIR from a shell script (setting it in Python is too late for child processes), wipe that directory on every start, build a per-request CollectorRegistry with support_collectors_without_names=True wrapped in a MultiProcessCollector, and add a gunicorn child_exit hook calling mark_process_dead. Skip any of those and you get metrics that silently double-count, go stale, or return nothing at all.

Patterns

Count eventscounter-basics

from prometheus_client import Counter

REQUESTS = Counter(
    'app_requests_total', 'Total HTTP requests', ['method', 'status']
)

REQUESTS.labels('GET', '200').inc()
REQUESTS.labels(method='POST', status='500').inc()

# count exceptions raised inside a block
ERRORS = Counter('app_task_failures_total', 'Failed tasks')
with ERRORS.count_exceptions(ValueError):
    parse(payload)

Counters only go up, and the _total suffix is the naming convention Prometheus tooling expects. Declare metrics at module scope: creating the same name twice against the default registry raises a duplicate registration error, which is why this blows up when a module is imported under two different names.

Measure latency with buckets you chosehistogram-latency

from prometheus_client import Histogram

LATENCY = Histogram(
    'app_request_duration_seconds',
    'Request latency',
    ['endpoint'],
    buckets=(0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10),
)

with LATENCY.labels('/search').time():
    run_search()

@LATENCY.labels('/checkout').time()
def checkout():
    ...

Default buckets are .005 through 10 seconds and are tuned for web request latency; if you measure batch jobs in minutes, every observation lands in +Inf and histogram_quantile returns nonsense. Each bucket is its own time series per label combination, so a nine-bucket histogram with twenty endpoints is over two hundred series before you add anything else.

Track a value that goes up and downgauge-current-value

from prometheus_client import Gauge

QUEUE_DEPTH = Gauge('app_queue_depth', 'Jobs waiting')
QUEUE_DEPTH.set(len(queue))
QUEUE_DEPTH.inc()
QUEUE_DEPTH.dec()

IN_PROGRESS = Gauge('app_inflight_requests', 'Requests being served')
with IN_PROGRESS.track_inprogress():
    handle(request)

# compute lazily at scrape time instead of storing
Gauge('app_cache_entries', 'Cache size').set_function(lambda: len(cache))

set_function runs your callback on every scrape, so keep it cheap and non-blocking; it is also one of the features that stops working entirely in multiprocess mode. track_inprogress is safe against exceptions, which a manual inc/dec pair is not.

Pre-create label sets and clean them uplabel-hygiene

from prometheus_client import Counter

RESULTS = Counter('app_jobs_total', 'Jobs', ['kind', 'outcome'])

# without this, a series does not exist until it first increments,
# so rate() over a quiet metric returns nothing rather than zero
for kind in ('import', 'export'):
    for outcome in ('ok', 'error'):
        RESULTS.labels(kind, outcome)

RESULTS.remove('import', 'error')
RESULTS.remove_by_labels({'kind': 'export'})

Label values must be a small closed set. Anything user-supplied (id, email, full URL path) creates a child object per distinct value that is never garbage collected, which is the most common way a Python service and its Prometheus server both fall over. remove and remove_by_labels exist for genuinely dynamic sets, and neither works in multiprocess mode.

Serve /metrics from a background threadexpose-http-endpoint

from prometheus_client import start_http_server

server, thread = start_http_server(8000)

# shut down cleanly, e.g. in a test fixture
server.shutdown()
server.server_close()
thread.join()

This runs in a daemon thread on its own port, which is the right shape for workers and daemons that have no HTTP server of their own. Bind it to an internal interface or firewall the port: the endpoint is unauthenticated and leaks your internal route names and process details. TLS and mTLS are available via certfile, keyfile, and client_auth_required.

Add /metrics to an existing appmount-in-web-framework

# FastAPI / Starlette
from prometheus_client import make_asgi_app
app.mount('/metrics', make_asgi_app())

# Flask / any WSGI app
from werkzeug.middleware.dispatcher import DispatcherMiddleware
from prometheus_client import make_wsgi_app
app.wsgi_app = DispatcherMiddleware(app.wsgi_app, {'/metrics': make_wsgi_app()})

Mounting inside your app means the endpoint inherits your auth middleware and your worker model, which is usually what you want. It also means metrics are only served while the app can serve traffic, so a wedged event loop looks identical to a dead process to Prometheus.

Make metrics work behind gunicorn workersmultiprocess-gunicorn

# start.sh (must be set before Python starts, not from inside it)
export PROMETHEUS_MULTIPROC_DIR=/tmp/prom
rm -rf "$PROMETHEUS_MULTIPROC_DIR" && mkdir -p "$PROMETHEUS_MULTIPROC_DIR"
exec gunicorn -c gunicorn.conf.py app:app

# gunicorn.conf.py
from prometheus_client import multiprocess

def child_exit(server, worker):
    multiprocess.mark_process_dead(worker.pid)

# metrics endpoint
from prometheus_client import CollectorRegistry, generate_latest, multiprocess

def metrics(environ, start_response):
    registry = CollectorRegistry(support_collectors_without_names=True)
    multiprocess.MultiProcessCollector(registry)
    data = generate_latest(registry)
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [data]

Build the registry inside the request, not at import time, or the request-serving process registers its own metrics into the same registry and every series is exported twice. The directory must be wiped before startup because dead workers leave files behind and their values keep being summed. Gauges need an explicit multiprocess_mode such as 'livesum' or you get one series per pid.

Expose numbers you already havecustom-collector

from prometheus_client.core import GaugeMetricFamily, REGISTRY
from prometheus_client.registry import Collector

class PoolCollector(Collector):
    def collect(self):
        g = GaugeMetricFamily(
            'db_pool_connections', 'Pool connections', labels=['state']
        )
        g.add_metric(['idle'], pool.idle_count())
        g.add_metric(['in_use'], pool.checked_out())
        yield g

REGISTRY.register(PoolCollector())

A custom collector is the right answer when the value already lives somewhere else (a connection pool, a queue library, a /proc file) and mirroring it into a Gauge would just drift. collect() runs on every scrape, so anything slow in there becomes scrape timeouts. Custom collectors do not work at all in multiprocess mode.

Report from a job that exitsbatch-job-metrics

from prometheus_client import CollectorRegistry, Gauge, write_to_textfile, push_to_gateway

registry = CollectorRegistry()
duration = Gauge('batch_duration_seconds', 'Run time', registry=registry)
duration.set(elapsed)
Gauge('batch_last_success_timestamp', 'Last OK', registry=registry).set_to_current_time()

# preferred: node exporter picks this up from its textfile directory
write_to_textfile('/var/lib/node_exporter/textfile/mybatch.prom', registry)

# only when no shared filesystem exists
push_to_gateway('pushgateway:9091', job='mybatch', registry=registry)

Use a fresh CollectorRegistry so you push only this job's numbers and not the process and GC collectors. write_to_textfile writes atomically via a temp file and rename. Pushed metrics stay in the Pushgateway until something deletes them, so a job that stops running keeps reporting its last value forever, which is why a last_success timestamp beats a success boolean.

Assert on a metric in a testtest-metrics

from prometheus_client import CollectorRegistry, Counter

def test_counts_failures():
    registry = CollectorRegistry()
    failures = Counter('failures_total', 'Failures', ['kind'], registry=registry)

    failures.labels('timeout').inc()

    assert registry.get_sample_value(
        'failures_total', {'kind': 'timeout'}
    ) == 1.0

Never assert against the global REGISTRY: test order changes the values and parallel runs collide. Passing registry=None skips registration entirely, which is handy for a metric you only want to construct. get_sample_value returns None rather than 0 when the label set has never been touched.

Drop the default process and GC metricstrim-default-collectors

from prometheus_client import REGISTRY, GC_COLLECTOR, PLATFORM_COLLECTOR, PROCESS_COLLECTOR

REGISTRY.unregister(GC_COLLECTOR)
REGISTRY.unregister(PLATFORM_COLLECTOR)
REGISTRY.unregister(PROCESS_COLLECTOR)

Importing prometheus_client registers all three automatically. Removing them is worth doing when another exporter already reports process stats for the same target, since duplicate python_gc_objects_collected_total series across a large fleet is real storage. Do the unregister before any scrape can happen.

Attach a trace id to an observationlink-traces-with-exemplars

from prometheus_client import Counter, Histogram

REQUESTS = Counter('app_requests_total', 'Requests', ['endpoint'])
LATENCY = Histogram('app_request_duration_seconds', 'Latency')

REQUESTS.labels('/search').inc(exemplar={'trace_id': trace_id})
LATENCY.observe(elapsed, {'trace_id': trace_id})

Exemplars only render in the OpenMetrics exposition format, which the built-in HTTP server negotiates with Prometheus automatically but a hand-rolled endpoint using the default generate_latest will not. The Prometheus server also needs --enable-feature=exemplar-storage, and exemplars are unsupported in multiprocess mode.

Alternatives

PackageRegistryPick it when
opentelemetry-sdkPyPIYou want traces, metrics, and logs under one vendor-neutral SDK and can export to Prometheus at the collector instead
prometheus-fastapi-instrumentatorPyPIYou just want request count, latency, and size metrics on a FastAPI app without writing the middleware yourself
statsdPyPIShort-lived processes or a push-based pipeline, where fire-and-forget UDP suits you better than being scraped