OpenTelemetry vs Prometheus for Python Metrics

Choosing between OpenTelemetry and Prometheus for a Python service is really a choice between a push pipeline and a pull pipeline, and between two instrumentation libraries — the OpenTelemetry metrics SDK and prometheus_client — that record the same metric types but ship them very differently. This decision shapes how you deploy, how you control cost, and how metrics correlate with your other signals. This guide is part of the Python Metrics and Instrumentation guide, and it builds on the two implementation walkthroughs it compares: instrumenting with prometheus_client and recording metrics with the OpenTelemetry metrics SDK. The goal here is a defensible decision, not a feature list — and because the two stacks can coexist, the answer for most teams is a sequence rather than a side.

The same measurement, two transports, one shared destination A single service records one measurement. The upper path hands it to prometheus_client, which keeps it in an in-process registry and serialises it as exposition text on a GET of the metrics endpoint that the Prometheus server initiates. The lower path hands it to the OpenTelemetry SDK, whose reader thread pushes it over OTLP to a collector and on to any backend. A dashed bridge in the middle, the PrometheusMetricReader, takes OpenTelemetry instruments and exposes them for the Prometheus server to scrape, which is why the two stacks can coexist during a migration. pull — the monitoring system decides when, and a missed scrape is itself a signal your service one measurement prometheus_client in-process registry GET /metrics exposition text Prometheus TSDB · PromQL OpenTelemetry SDK views + reader thread OTLP export gRPC or HTTP Collector then any backend PrometheusMetricReader OTel instruments, scraped scraped as usual push — the application decides when, so retry and buffering become its problem
The honest comparison is not "OpenTelemetry versus Prometheus" but two transports out of the same service — and the dashed bridge means Prometheus can be the destination of either.

Prerequisites

To evaluate both paths on the same service, install both stacks in one virtual environment. The three OpenTelemetry packages share a release train and must be pinned to matching ranges; opentelemetry-exporter-prometheus versions independently and still carries a beta marker, so pin it explicitly rather than letting a resolver float it.

python -m venv .venv && source .venv/bin/activate
pip install \
  "prometheus-client>=0.20.0,<1.0.0" \
  "opentelemetry-api>=1.30.0,<2.0.0" \
  "opentelemetry-sdk>=1.30.0,<2.0.0" \
  "opentelemetry-exporter-otlp-proto-grpc>=1.30.0,<2.0.0" \
  "opentelemetry-exporter-prometheus>=0.50b0,<1.0.0"

In a service repository, express the same pins as optional dependency groups so a deployment installs only the transport it actually uses and the other one never reaches the runtime image:

[project.optional-dependencies]
prometheus = [
  "prometheus-client>=0.20.0,<1.0.0",
]
otlp = [
  "opentelemetry-api>=1.30.0,<2.0.0",
  "opentelemetry-sdk>=1.30.0,<2.0.0",
  "opentelemetry-exporter-otlp-proto-grpc>=1.30.0,<2.0.0",
]
bridge = [
  "prometheus-client>=0.20.0,<1.0.0",
  "opentelemetry-sdk>=1.30.0,<2.0.0",
  "opentelemetry-exporter-prometheus>=0.50b0,<1.0.0",
]

Each model needs different environment setup. The pull model needs only a port the scraper can reach, plus a shared directory when the process forks. The push model needs service identity and an endpoint, which the SDK reads directly from the environment so the same image runs in every deployment:

# Pull model: the port Prometheus scrapes, and multiprocess coordination
export METRICS_PORT="9100"
export PROMETHEUS_MULTIPROC_DIR="/dev/shm/prom"       # required under gunicorn

# Push model: identity plus destination, read by the SDK with no code change
export OTEL_SERVICE_NAME="order-service"
export OTEL_RESOURCE_ATTRIBUTES="service.version=2.4.0,deployment.environment=production"
export OTEL_EXPORTER_OTLP_METRICS_ENDPOINT="http://otel-collector:4317"
export OTEL_METRIC_EXPORT_INTERVAL="15000"            # milliseconds

Keep PROMETHEUS_MULTIPROC_DIR on a tmpfs such as /dev/shm and empty it on every process start, otherwise stale files from a previous container generation are summed into the current scrape.

Concept and architecture

The deepest difference is who initiates data movement. In the pull model, the application is passive: prometheus_client keeps current values in an in-process registry and exposes a /metrics endpoint, and a Prometheus server decides when to scrape it. The monitoring system owns timing and discovery, and a failed scrape is itself a useful liveness signal. In the push model, the application is active: the OpenTelemetry SDK runs a PeriodicExportingMetricReader that collects instrument values and sends them over OTLP to a collector or backend on a cadence the app controls.

These models imply different operational shapes. Pull needs the app to be network-reachable by the scraper and works best for long-lived processes with stable addresses — classic Kubernetes pods behind service discovery. Push works when the app cannot be scraped: short-lived batch jobs, serverless functions, or processes behind a NAT, where the workload may exit before any scrape would have fired. Push also folds metrics into the same OTLP pipeline used for distributed traces, giving you one exporter, one endpoint, and one resource definition across signals.

The reliability characteristics differ too. With pull, the monitoring system has a built-in health check: if a scrape fails, Prometheus records the target as up == 0, and you get a free signal that the process is unreachable. There is no need for the app to retry or buffer, because the next scrape simply reads whatever the current value is. With push, the application owns delivery, so transient export failures must be retried and ideally buffered, which is exactly what the OpenTelemetry Collector adds in front of the backend. The flip side is that pull cannot capture a metric from a process that has already exited, whereas push can flush a final export on shutdown — the deciding factor for batch and serverless workloads.

How each model behaves when something goes wrong Three rows. When the target is unreachable, pull records up equals zero on the next scrape so the miss is itself the alert, while push produces silence that is indistinguishable from an idle service. When a single transfer fails, pull loses nothing because the next scrape reads the current total, while push must retry from the exporter and relies on the Collector buffering in front of the backend. When the process exits within seconds, pull captures no data at all because it exited between two scrapes, while push still lands a final flush from force_flush followed by shutdown. what happens when… pull — prometheus_client push — OpenTelemetry SDK target unreachable the process is down up == 0 on the next scrape the miss is itself the alert silence that looks like idle nothing separates down from quiet one transfer fails a single hop drops nothing is lost the next scrape reads the current total the exporter must retry and the Collector buffers in front the process exits a three-second batch job no data at all it exited between two scrapes a final flush still lands force_flush() before shutdown() neither model is more reliable — they fail differently, and your workload picks the winner
Reliability is not a scoreboard here: pull turns delivery failure into a free liveness signal, push turns process death into a recoverable one.

The libraries you actually write against

prometheus_client is small and synchronous: instruments mutate in memory, serialization happens at scrape, there is no background thread in the simple case. Its object model is four metric classes plus a REGISTRY, and its configuration surface is mostly naming and labels. That minimalism is a genuine advantage — there is very little to misconfigure, and the whole library fits in an afternoon of reading.

The OpenTelemetry metrics SDK is a fuller pipeline — MeterProvider, readers, exporters, and views that can rename instruments, drop attributes, or reshape histogram buckets before export. That richness costs a background reader/exporter thread and more configuration surface, which is the price of vendor neutrality and cross-signal unification. It also moves shaping decisions from the monitoring system into the application: with Prometheus, you drop a noisy label with metric_relabel_configs in the server config; with OpenTelemetry, you drop it with a View in the app, which means a redeploy but also means the noisy label never crosses the network. Which side you want that control on is a real organisational question, not just a technical one.

Where storage and query live

Storage and query are where the two stop overlapping. Prometheus is not only a scraper but a complete time series database with its own storage engine and the PromQL query language; choosing Prometheus instrumentation usually means choosing the whole Prometheus stack — server, storage, alertmanager, and PromQL-driven dashboards. OpenTelemetry deliberately stops at instrumentation and transport: it defines how a metric is recorded and shipped but has no storage or query layer of its own. An OTLP pipeline must terminate in some backend, which can be Prometheus (via remote-write or the exporter bridge), a managed vendor, or another OTLP-native store. That separation is the whole point — OpenTelemetry decouples how you instrument from where you store, so you can change backends without rewriting application code.

This is why "OpenTelemetry vs Prometheus" is a slightly unfair framing. The honest comparison is prometheus_client versus the OpenTelemetry metrics SDK at the instrumentation layer, and scrape-plus-TSDB versus OTLP-plus-Collector at the transport layer. Prometheus can be the storage engine in either case.

Temporality: the subtle one

A second architectural consequence is temporality. Prometheus only understands cumulative values: a counter is the running total since process start, and PromQL functions like rate() derive per-second rates by differencing successive scrapes. Prometheus also tracks a per-series start time so it can detect a counter reset when a process restarts and the total drops back to zero. The OpenTelemetry SDK can export either cumulative or delta temporality, where delta reports the change since the last export.

Delta suits short-lived workloads that never accumulate a meaningful long-run total, and it lowers memory in processes with many transient series because state is reset after each export. But if your backend is Prometheus you must keep cumulative temporality so rate() behaves correctly — a delta stream fed into a store that expects monotonic totals produces rate graphs that collapse toward zero or spike wildly, depending on how the ingester interprets the reset. Cumulative also tolerates a dropped export: the next successful export still carries the full running total, so no data is lost. A dropped delta export loses that interval permanently.

Cumulative self-heals after a dropped export; delta does not The same counter is exported six times. Read as cumulative, the exported values are the running totals twelve, twenty-one, thirty-six, forty-seven, fifty-four and sixty-four, so the fourth export being dropped costs nothing: the fifth export carries fifty-four, which still contains the eleven that never shipped. Read as delta, the exported values are the per-interval increments twelve, nine, fifteen, eleven, seven and ten, so the dropped fourth export destroys that interval's eleven permanently — the fifth export reports only its own seven and no later value can recover the gap. one counter, six export intervals — same measurements, two temporalities export 4 dropped cumulative running total 12 21 36 (47) 54 64 t1 t2 t3 t4 t5 t6 t5 still carries the 11 that never shipped delta per interval 12 9 15 (11) 7 10 t1 t2 t3 t4 t5 t6 that interval is gone for good
The dropped export is the whole argument: cumulative repairs itself on the next send, delta cannot, which is why Prometheus downstream means cumulative upstream.

Naming and semantic conventions

The two ecosystems name things differently, and this bites during migration more often than the transport does. Prometheus convention is a snake_case name with the unit as a suffix and _total on counters: http_request_duration_seconds, http_requests_total. OpenTelemetry uses dotted namespaces with the unit as a separate field on the instrument: http.server.request.duration with unit="s".

The Prometheus exporter translates between them mechanically. Dots become underscores, the unit is appended if it is not already in the name, monotonic counters gain _total, and every series picks up otel_scope_name and otel_scope_version labels identifying the instrumentation scope. The result is a valid Prometheus series that is not byte-identical to what prometheus_client would have produced for the same concept — which is exactly why dashboards and alert rules break on cut-over. If you need continuity, use a View to rename the instrument to the legacy name before export rather than leaving the translation to chance. The label side of this is covered in controlling label cardinality in Prometheus, where the same allow-list discipline applies to OTel attributes.

Step-by-step decision

Work through these questions in order; the first decisive answer usually settles it.

Step 1. Do you already run Prometheus, and are your services long-lived and scrapeable? If yes, the pull model with prometheus_client is the lowest-friction path — no exporters to configure, no collector to run, and the scrape itself doubles as a liveness check. Expose the endpoint and let service discovery find it.

# pip install "prometheus-client>=0.20.0,<1.0.0"
from prometheus_client import Counter, start_http_server

# Labels are bounded on purpose: route template, not raw path; status class, not code.
requests = Counter("http_requests_total", "Requests.", ["route", "status"])
start_http_server(9100)  # Prometheus scrapes :9100/metrics
requests.labels("/orders/{id}", "200").inc()

Expected Output: a scrape of :9100/metrics returns the exposition text Prometheus parses directly:

# HELP http_requests_total Requests.
# TYPE http_requests_total counter
http_requests_total{route="/orders/{id}",status="200"} 1.0

Step 2. Do you need one pipeline shared with traces and logs, or vendor neutrality? If you want a single OTLP path to a collector that you can re-route without touching app code — and especially if you already run OpenTelemetry SDK setup for tracing — choose the OpenTelemetry SDK and push. The same Resource object then labels traces and metrics identically, which is what makes cross-signal navigation work.

# pip install "opentelemetry-sdk>=1.30.0,<2.0.0" \
#   "opentelemetry-exporter-otlp-proto-grpc>=1.30.0,<2.0.0"
from opentelemetry import metrics
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter

# The reader owns the cadence; the exporter owns the destination.
reader = PeriodicExportingMetricReader(OTLPMetricExporter(endpoint="localhost:4317"))
metrics.set_meter_provider(MeterProvider(metric_readers=[reader]))
counter = metrics.get_meter("svc").create_counter("http.server.requests")
counter.add(1, {"http.route": "/orders/{id}", "http.status_code": 200})

Expected Output: on the next interval the Collector logs a data point carrying the scope and attributes verbatim:

Metric #0
Descriptor: -> Name: http.server.requests -> DataType: Sum -> IsMonotonic: true
     -> Temporality: Cumulative
NumberDataPoints #0
Attributes: http.route: /orders/{id}, http.status_code: 200
Value: 1

Step 3. Are your workloads short-lived or unscrapeable? Batch jobs, cron tasks, and serverless functions favour push, because a scraper on a 15-second interval may never reach a process that exits in three seconds. Prefer OTLP with an explicit flush on exit; reach for a Prometheus push-gateway only for genuine batch jobs, and never for long-running services, where it turns the gateway into a single point of stale data.

# pip install "opentelemetry-sdk>=1.30.0,<2.0.0" \
#   "opentelemetry-exporter-otlp-proto-grpc>=1.30.0,<2.0.0"
provider = MeterProvider(metric_readers=[reader])
metrics.set_meter_provider(provider)
try:
    run_nightly_reconciliation()
finally:
    provider.force_flush(timeout_millis=5_000)  # push the final window
    provider.shutdown()                          # stop the reader thread cleanly

Step 4. Do you want OTel instrumentation but Prometheus storage? Use the bridge: the SDK's PrometheusMetricReader exposes a scrapeable endpoint while you write code against the OpenTelemetry API. This is the pragmatic middle for teams that want to standardise instrumentation now and decide on storage later.

# pip install "opentelemetry-sdk>=1.30.0,<2.0.0" \
#   "opentelemetry-exporter-prometheus>=0.50b0,<1.0.0" \
#   "prometheus-client>=0.20.0,<1.0.0"
from prometheus_client import start_http_server
from opentelemetry import metrics
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.exporter.prometheus import PrometheusMetricReader

reader = PrometheusMetricReader()           # OTel instruments -> exposition format
metrics.set_meter_provider(MeterProvider(metric_readers=[reader]))
start_http_server(9100)                      # Prometheus scrapes as usual
metrics.get_meter("svc").create_counter("http.server.requests").add(1)

Expected Output: A scrape of :9100/metrics shows the OTel-created instrument rendered in Prometheus exposition format, with the dotted name flattened and the scope label added:

# TYPE http_server_requests_total counter
http_server_requests_total{otel_scope_name="svc"} 1.0

Step 5. Whatever you chose, bound the series before it ships. The transport decision does not change the cost driver: series count does. On the Prometheus side that means metric_relabel_configs dropping high-cardinality labels at ingestion; on the OpenTelemetry side it means a View with attribute_keys set to an allow-list. Decide the bounded label set at the same time as you decide the instrument — see choosing between counter, gauge, histogram, and summary for how instrument choice and cardinality interact.

The four questions, in the order that settles the decision Question one asks whether Prometheus is already running and every process is long-lived and scrapeable; yes leads to prometheus_client with a scrape, where the scrape doubles as a liveness check. Question two asks whether one pipeline shared with traces and logs, or vendor neutrality, is required; yes leads to the OpenTelemetry SDK with OTLP push and one Resource shared across signals. Question three asks whether the work is short-lived or unscrapeable; yes leads to OTLP push with an explicit force_flush so the final window leaves before exit. Question four asks whether you want the OpenTelemetry API but Prometheus storage; yes leads to the PrometheusMetricReader bridge. All four outcomes feed a fifth step: bound the series before it ships, using metric_relabel_configs on the server side or a View with attribute_keys in the app. work down the questions — the first yes settles it 1 · Prometheus already running? and every process long-lived and scrapeable prometheus_client + scrape the scrape doubles as a liveness check 2 · one pipeline with traces? or vendor neutrality you cannot give up OpenTelemetry SDK + OTLP one Resource shared with traces and logs 3 · short-lived or unscrapeable? batch jobs, cron tasks, serverless OTLP push + force_flush() the final window leaves before exit 4 · OTel API, Prometheus store? standardise now, choose the store later PrometheusMetricReader OTel instruments, Prometheus scrape yes yes yes yes no no no 5 · bound the series before it ships — every branch ends here metric_relabel_configs on the server side · View(attribute_keys=…) in the app
The transport question has four exits and one shared gate: whichever branch you take, series count is still the thing that costs money.

Configuration reference

The capability matrix first — this is the shape of the decision, dimension by dimension:

Dimension Prometheus (prometheus_client) OpenTelemetry (metrics SDK)
Transport model Pull / scrape Push (also pull via bridge)
Wire format Exposition text (text/plain; version=0.0.4) OTLP (gRPC or HTTP/protobuf)
Core objects Counter, Gauge, Histogram, Summary, REGISTRY MeterProvider, Meter, PeriodicExportingMetricReader, OTLPMetricExporter
Export trigger On scrape, server-driven On interval, app-driven
Naming style snake_case_with_unit_suffix dotted.namespace + separate unit
Multiprocess PROMETHEUS_MULTIPROC_DIR + MultiProcessCollector Per-process provider, exported with instance attributes
Histogram quantiles Computed server-side from buckets Buckets via views; quantiles computed in backend
Attribute/series shaping metric_relabel_configs at ingestion Views (rename, drop attributes, change buckets)
Temporality Cumulative only Cumulative or delta (configurable)
Cross-signal correlation Exemplars (trace_id on buckets) Native resource shared with traces/logs
Collector required No Recommended, not required

And the settings you will actually set, with the value worth defaulting to in production:

Setting Stack Type Default Production value
start_http_server(port) Prometheus int none 9100 on a port not exposed publicly
PROMETHEUS_MULTIPROC_DIR Prometheus path unset tmpfs path, emptied on start, required under gunicorn
scrape_interval Prometheus duration 1m 15s, and never longer than the shortest alert window
metric_relabel_configs Prometheus list empty drop rules for any unbounded label
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT OpenTelemetry URL http://localhost:4317 an internal Collector address, not the vendor endpoint directly
OTEL_METRIC_EXPORT_INTERVAL OpenTelemetry ms 60000 15000 to match a typical scrape cadence
OTEL_METRIC_EXPORT_TIMEOUT OpenTelemetry ms 30000 below the export interval so exports cannot overlap
preferred_temporality OpenTelemetry mapping cumulative cumulative whenever Prometheus is downstream
View(attribute_keys=...) OpenTelemetry set all attributes kept explicit allow-list per instrument
OTEL_METRICS_EXEMPLAR_FILTER OpenTelemetry enum trace_based trace_based, so exemplars attach only on sampled spans

Constructor arguments always beat environment variables in the OpenTelemetry SDK, so put values that are true of the service everywhere (bucket boundaries, attribute allow-lists) in code, and values that differ per deployment (endpoint, interval, resource attributes) in the environment.

Two configuration surfaces, split in different places On the Prometheus side, the application owns start_http_server, the CollectorRegistry and PROMETHEUS_MULTIPROC_DIR, while prometheus.yml on the server owns scrape_interval, metric_relabel_configs and relabel_configs for target discovery — so the app exposes and the server decides what is stored. On the OpenTelemetry side, constructor arguments in code own Views with attribute_keys, explicit histogram bucket boundaries and preferred_temporality, while the environment owns OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES, OTEL_EXPORTER_OTLP_METRICS_ENDPOINT and OTEL_METRIC_EXPORT_INTERVAL. When both set the same knob, the constructor argument wins. Prometheus OpenTelemetry in the application start_http_server(9100) CollectorRegistry / REGISTRY PROMETHEUS_MULTIPROC_DIR in prometheus.yml scrape_interval: 15s metric_relabel_configs: drop rules relabel_configs: target discovery constructor arguments View(attribute_keys={…}) ExplicitBucketHistogramAggregation preferred_temporality=CUMULATIVE OTEL_* environment OTEL_SERVICE_NAME, RESOURCE_ATTRIBUTES OTEL_EXPORTER_OTLP_METRICS_ENDPOINT OTEL_METRIC_EXPORT_INTERVAL=15000 exposed, then shaped overrides the app exposes; the server decides what is stored code beats environment when both set the same knob
The split is the real difference: Prometheus lets you reshape data after it leaves the process, OpenTelemetry makes you decide before it does.

Async and concurrency considerations

Both libraries are safe to call from async handlers — recording a counter or histogram is a fast, non-blocking in-memory operation in each, guarded by a lock held for the duration of an integer addition. Neither one performs I/O on the calling path: prometheus_client serialises only when the scrape handler runs, and the OpenTelemetry SDK exports only from its reader thread. Under the GIL this means the observable cost of an inc() or an add() is a handful of microseconds and does not yield the event loop.

The concurrency concern is process topology, not coroutines. Under gunicorn or multi-worker uvicorn, the pull model requires multiprocess mode so a scrape aggregates across workers rather than hitting one; without it, totals are silently wrong and appear to jump around as the load balancer routes the scrape to different workers. The same forking constraint applies to OpenTelemetry but inverts: build the MeterProvider after the worker forks (in a post_fork hook or ASGI lifespan), because the exporter's background thread does not survive fork(). A provider created at import time in the master process leaves every worker with a reader that never wakes.

There is a second asymmetry worth planning for. Multiprocess prometheus_client cannot express every metric type faithfully: gauges need an explicit multiprocess mode (livesum, max, min, or all) because "the current value" is ambiguous across workers, and Summary quantiles cannot be aggregated at all. OpenTelemetry sidesteps this by keeping each process independent and letting the backend aggregate, at the cost of multiplying series by the worker count unless you strip the per-process attribute.

For values you sample rather than increment — queue depth, pool size, resident memory — both offer a pull-at-collection pattern: a Gauge with a set_function callback in prometheus_client, or an observable gauge with a callback in OpenTelemetry. Use these so you never block a request path to read an expensive value. The callback fires at scrape or collection time on the collection thread, keeping your handlers fast — and keep those callbacks free of await-shaped work, because both run them synchronously on a non-async thread.

The fork boundary, and what each model does below it A gunicorn master imports the module once and then forks four workers. Under the pull model each worker only increments in memory and writes into a shared PROMETHEUS_MULTIPROC_DIR holding one memory-mapped file per worker on tmpfs, and a MultiProcessCollector sums every file at scrape time so one scrape sees the whole fleet. Under the push model each worker builds its own MeterProvider with its own reader thread and exports OTLP to the Collector independently, giving four separate streams that the backend must aggregate and that multiply series by the worker count. Anything built above the fork boundary does not survive the fork. four gunicorn workers — the same fork, two different consequences gunicorn master — imports the module once, then forks fork() boundary pull — one shared file set push — one provider per worker worker 1 inc() only worker 2 inc() only worker 3 inc() only worker 4 inc() only PROMETHEUS_MULTIPROC_DIR one mmap file per worker, on tmpfs reads and sums at scrape MultiProcessCollector one scrape sees the whole fleet worker 1 MeterProvider reader thread worker 2 MeterProvider reader thread worker 3 MeterProvider reader thread worker 4 MeterProvider reader thread OTLP → Collector four independent streams one stream per worker the backend aggregates series multiply by worker count a provider built above the line leaves every worker with a reader thread that never wakes
Process topology, not coroutines, is the concurrency problem: one model shares state through files, the other keeps each worker independent and pays for it in series count.

Ecosystem and auto-instrumentation

Library maturity often decides the question more than the model does. Prometheus has a long-established ecosystem: client libraries in every language, exporters for databases and message brokers, and a vast catalogue of community dashboards and alert rules built around PromQL. For a Python team standardized on Prometheus, the integrations for Flask, Django, Celery, and the common databases are battle-tested, and the prometheus_client instrumentation guide covers the framework hooks directly. The cost is that this ecosystem is Prometheus-shaped — moving off it later means re-instrumenting or relying on bridges.

OpenTelemetry's advantage is breadth across signals and vendors. The same project that emits your metrics also emits your traces and logs, and a single set of opentelemetry-instrumentation-* packages auto-instruments web frameworks and clients for all three signals at once. If you have already adopted OpenTelemetry for distributed tracing in Python, extending it to metrics reuses the resource definition, the collector, and the operational know-how you already have. It also gives you exemplars for free on sampled requests, so a latency histogram bucket links straight to a trace — the metric-to-trace hop that otherwise requires manual work, and the natural companion to adding trace IDs to log records. The trade-off is that the metrics half of OpenTelemetry stabilized later than tracing, so some exporter and auto-instrumentation packages still carry beta version markers and need version-pinning discipline.

A realistic recommendation: greenfield services on a platform that already standardizes on OpenTelemetry should use the OTel SDK and OTLP; teams with an entrenched Prometheus stack and long-lived scrapeable services should use prometheus_client; and teams mid-transition should run the bridge so they can change the backend on their own schedule rather than the application's.

Where each ecosystem is strong Six capabilities, two ecosystems. For web framework instrumentation Prometheus is mature across Flask, Django and FastAPI while OpenTelemetry is broad with one package per library. For database and cache clients Prometheus has many mature exporters while OpenTelemetry delivers traces and metrics together. For task queues such as Celery and RQ Prometheus relies on good community exporters while OpenTelemetry auto-instruments them. For ready-made dashboards and alert rules Prometheus is deep after a decade of PromQL while OpenTelemetry content is usually rebuilt per backend. For cross-signal correlation Prometheus offers exemplars on sampled requests while OpenTelemetry is native across all three signals. For vendor portability Prometheus is Prometheus-shaped by design while OpenTelemetry lets you swap the backend and keep the code. capability Prometheus ecosystem OpenTelemetry ecosystem web framework instrumentation mature — Flask, Django, FastAPI broad — one package per library database and cache clients mature — many exporters broad — traces + metrics task queues — Celery, RQ good — community exporters broad — auto-instrumented ready-made dashboards and alerts deep — a decade of PromQL thin — rebuilt per backend cross-signal correlation exemplars on sampled requests native across all three signals vendor portability Prometheus-shaped by design swap the backend, keep code Prometheus wins on ready-made operations; OpenTelemetry wins on one pipeline for three signals
Library maturity decides more arguments than architecture does — and the two ecosystems are strong at opposite ends of the same list.

Production code examples

Migration bridge: instrument once, serve both

A common real-world state is a fleet mid-migration: new code uses the OpenTelemetry API, but the platform team still runs Prometheus. The bridge lets both coexist with no double instrumentation. One MeterProvider can carry two readers, so the same instrument is simultaneously scrapeable and pushed — which is what makes a cut-over reversible.

# pip install "opentelemetry-sdk>=1.30.0,<2.0.0" \
#   "opentelemetry-exporter-prometheus>=0.50b0,<1.0.0" \
#   "opentelemetry-exporter-otlp-proto-grpc>=1.30.0,<2.0.0" \
#   "prometheus-client>=0.20.0,<1.0.0"
from prometheus_client import start_http_server
from opentelemetry import metrics
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.prometheus import PrometheusMetricReader
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter

# 1. One Resource, shared with the TracerProvider so signals agree on identity.
resource = Resource.create({"service.name": "order-service"})

# 2. Two readers on one provider: scrapeable endpoint AND OTLP push.
prom_reader = PrometheusMetricReader()
otlp_reader = PeriodicExportingMetricReader(
    OTLPMetricExporter(endpoint="localhost:4317"), export_interval_millis=10_000
)
metrics.set_meter_provider(
    MeterProvider(resource=resource, metric_readers=[prom_reader, otlp_reader])
)

# 3. The exposition endpoint stays where the existing scrape config expects it.
start_http_server(9100)  # Prometheus still scrapes; OTLP also flows

# 4. Application code never learns which transport is in use.
meter = metrics.get_meter("order-service")
orders = meter.create_counter("orders.processed")
orders.add(1, {"tier": "gold"})

Expected Output: The same instrument is both scrapeable and pushed. The scrape shows orders_processed_total{otel_scope_name="order-service",tier="gold"} 1.0, while the collector receives an equivalent OTLP data point — letting you cut over backends without touching instrumentation code.

Note that each reader keeps its own aggregation state, so the scrape total and the pushed total are independent accumulators of the same measurements. They will agree in value but their reset points differ: the Prometheus view resets on process restart, the OTLP view resets whenever you switch temporality.

Keeping legacy metric names across the cut-over

The bridge preserves the transport but not the names. If existing alert rules query orders_processed_total with no otel_scope_name label matcher, they still match — Prometheus matchers ignore extra labels — but a rule that pins the full label set, or a recording rule keyed on an old name, will not. A View renames the instrument before either reader sees it, so the exposition output keeps the legacy name.

# pip install "opentelemetry-sdk>=1.30.0,<2.0.0" \
#   "opentelemetry-exporter-prometheus>=0.50b0,<1.0.0"
from opentelemetry.sdk.metrics.view import View, ExplicitBucketHistogramAggregation

views = [
    # 1. Keep the legacy Prometheus name for an instrument the dashboards already query.
    View(instrument_name="http.server.request.duration", name="http_request_duration_seconds",
         aggregation=ExplicitBucketHistogramAggregation(
             boundaries=[0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0]
         )),
    # 2. Allow-list attributes so the migration cannot widen cardinality by accident.
    View(instrument_name="*", attribute_keys={"http.route", "http.status_code"}),
]
provider = MeterProvider(resource=resource, metric_readers=[prom_reader], views=views)

Expected Output: the scrape emits the legacy series name with the legacy buckets, so existing PromQL keeps working while the instrumentation underneath is entirely OpenTelemetry:

# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{http_route="/orders/{id}",le="0.1",otel_scope_name="order-service"} 42.0
http_request_duration_seconds_sum{http_route="/orders/{id}",otel_scope_name="order-service"} 2.71
http_request_duration_seconds_count{http_route="/orders/{id}",otel_scope_name="order-service"} 57.0

Views bind when an instrument records its first measurement, so register every view at provider construction. Adding one later silently does nothing.

A cut-over that stays reversible until the last stage Stage one, at day zero, is prometheus_client scraped exactly as today with no OpenTelemetry anywhere, and it is the starting point. Stage two, around week one, moves application code to the OpenTelemetry API behind a PrometheusMetricReader so dashboards are unchanged, and reverting means dropping the SDK. Stage three, around weeks two to three, attaches two readers to one MeterProvider so OTLP runs in parallel with the existing scrape, and reverting means dropping one reader. Stage four happens only once both agree: the Prometheus reader is removed, OTLP is the only transport, queries move to the new store, and from here the change is one-way. one instrumentation change, four stages — reversible until the last stage 1 prometheus_client scraped as today no OTel anywhere the starting point stage 2 OTel API in the app PrometheusMetricReader dashboards unchanged revert = drop the SDK stage 3 two readers, one MeterProvider OTLP runs in parallel revert = drop a reader stage 4 Prometheus reader off OTLP only new store queries only one-way from here day 0 week 1 week 2–3 when both agree running two readers is what makes the cut-over reversible
Two readers on one provider is not redundancy for its own sake — it is the stage that lets you undo the migration without touching instrumentation again.

Common mistakes

  • Error signature: counters reset to small values on every scrape under gunicorn. Root cause: running the pull model with multiple workers and no multiprocess directory, so each scrape hits one worker. Remediation: set PROMETHEUS_MULTIPROC_DIR and build the scrape registry with MultiProcessCollector, or switch that service to OTLP push.
  • Error signature: OTLP metrics stop arriving from forked workers despite working in a single process. Root cause: the MeterProvider was created at import time, before fork(), so the exporter thread did not survive. Remediation: initialize the provider in a post_fork hook or lifespan startup.
  • Error signature: rate() graphs collapse to zero or spike after moving a service to OTLP. Root cause: the exporter was configured with delta temporality while Prometheus remote-write remained the destination, so the store saw per-interval values where it expected monotonic totals. Remediation: keep cumulative temporality whenever Prometheus is downstream, and only choose delta when the receiving backend documents delta support.
  • Error signature: a global p99 dashboard looks wrong after consolidating instances. Root cause: a Summary (or in-process quantile) was used; per-process quantiles cannot be aggregated. Remediation: switch to a histogram so the backend computes the quantile from summed buckets, as covered in choosing counter, gauge, histogram, and summary.
  • Error signature: Prometheus memory climbs steadily after adopting OTLP push. Root cause: OTel attributes were not constrained the way Prometheus labels are, reintroducing unbounded cardinality. Remediation: apply views that drop high-cardinality attributes before export, following controlling label cardinality.
  • Error signature: alerts go quiet after a migration even though the metric is visibly present. Root cause: the exporter translated orders.processed into orders_processed_total and attached otel_scope_name, so a rule matching the old name or pinning an exact label set no longer fires. Remediation: rename the instrument with a View before cut-over, and diff the scraped series names against the previous exposition output before retiring the old path.
Each symptom, and the layer it actually comes from Counters jumping around under gunicorn come from the process model, fixed by setting PROMETHEUS_MULTIPROC_DIR and building the scrape registry with MultiProcessCollector. OTLP going silent in forked workers comes from bootstrap order, fixed by building the provider in a post_fork hook or lifespan startup. Rate graphs collapsing or spiking after a move to OTLP come from temporality, fixed by keeping cumulative whenever Prometheus is downstream. A wrong global p99 comes from aggregation, fixed by using a histogram so the backend computes the quantile. Prometheus memory climbing after adopting push comes from cardinality, fixed by dropping attributes with a View before export. Alerts going quiet while the metric is still visible comes from naming, fixed by renaming the instrument with a View and diffing the series names before cut-over. symptom originating layer + fix counters jump around under gunicorn process model set the multiproc dir + MultiProcessCollector OTLP goes silent in forked workers bootstrap order build the provider in post_fork / lifespan rate() collapses or spikes after OTLP temporality keep cumulative whenever Prometheus is downstream the global p99 dashboard looks wrong aggregation use a histogram; let the backend compute p99 Prometheus memory climbs after push cardinality drop attributes with a View before export alerts go quiet, metric still there naming rename with a View, then diff the series names every one of these is a layer mismatch, not a bug in either library
None of these failures live in the library you chose — they live in the layer where the two models disagree.

Frequently Asked Questions

Is OpenTelemetry replacing Prometheus for Python metrics?

No. They solve overlapping but distinct problems. OpenTelemetry is an instrumentation and transport standard with a push model; Prometheus is a storage and query system with a pull model. Many teams instrument with OpenTelemetry and still store and query in Prometheus by using the OTel Prometheus exporter or remote-write.

Can I scrape OpenTelemetry metrics with Prometheus?

Yes. The OpenTelemetry SDK ships a PrometheusMetricReader that exposes a scrapeable exposition endpoint, so you can instrument with the OTel API and still let Prometheus pull. This is the usual bridge during a migration.

Which has lower overhead in a Python process, prometheus_client or the OTel SDK?

prometheus_client is lighter because it only maintains in-memory counters and serializes on scrape, with no background export. The OTel SDK runs a periodic reader and exporter thread, which is modest but non-zero. For most services the difference is negligible compared to request handling.

Do I need the OpenTelemetry Collector to use OTLP metrics?

Not strictly. You can export OTLP directly from the SDK to any OTLP-capable backend. The collector is recommended in production because it adds batching, retries, and the ability to reroute or filter without redeploying the app.

Should a new Python microservice start with pull or push?

If you already run Prometheus and your services are long-lived and scrapeable, start with the pull model and prometheus_client for the least friction. If you are building a vendor-neutral pipeline shared with traces and logs, or running short-lived or unscrapeable workloads, start with OpenTelemetry and OTLP push.

What happens to my metric names when I move from prometheus_client to OpenTelemetry?

OpenTelemetry uses dotted names and a separate unit field, such as http.server.request.duration in seconds, while Prometheus uses underscored names with the unit baked in, such as http_server_request_duration_seconds. The Prometheus exporter translates dots to underscores, appends the unit and the _total suffix for monotonic counters, and adds an otel_scope_name label. Expect PromQL queries and alert rules to need updating unless you rename instruments with a View.