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.
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.
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
- You run gunicorn or uwsgi with multiple worker processes and have not read the multiprocess mode page: it needs PROMETHEUS_MULTIPROC_DIR set outside Python, wiped before every start, plus a child_exit hook, and in exchange you lose custom collectors, Info and Enum metrics, Gauge.set_function, exemplars, the Pushgateway, and label removal
- You want quantiles from a Summary: this client does not compute them locally, so a Summary only ever gives you _count and _sum and p99 has to come from a Histogram with buckets you chose in advance
- Your workload is short-lived: a script that exits in two seconds is never scraped, and the Pushgateway workaround is explicitly discouraged by the Prometheus project for anything except service-level batch jobs
- You are already standardised on OpenTelemetry: running both means two SDKs, two sets of names, and two config surfaces for the same numbers
- You are careless with label values: every distinct combination allocates a new child object that lives for the process lifetime, so a user_id or a raw URL path label leaks memory in your app and blows up cardinality on the Prometheus server, and nothing in the library stops you
- You want a stable version number to point at in a compliance doc: eleven years in, this is still 0.26.0
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.0Never 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
| Package | Registry | Pick it when |
|---|---|---|
| opentelemetry-sdk | PyPI | You want traces, metrics, and logs under one vendor-neutral SDK and can export to Prometheus at the collector instead |
| prometheus-fastapi-instrumentator | PyPI | You just want request count, latency, and size metrics on a FastAPI app without writing the middleware yourself |
| statsd | PyPI | Short-lived processes or a push-based pipeline, where fire-and-forget UDP suits you better than being scraped |