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.
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.
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.
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.
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.
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.
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.
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.
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_DIRand build the scrape registry withMultiProcessCollector, 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
MeterProviderwas created at import time, beforefork(), so the exporter thread did not survive. Remediation: initialize the provider in apost_forkhook 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.processedintoorders_processed_totaland attachedotel_scope_name, so a rule matching the old name or pinning an exact label set no longer fires. Remediation: rename the instrument with aViewbefore cut-over, and diff the scraped series names against the previous exposition output before retiring the old path.
Related
- Python Metrics and Instrumentation — the parent guide covering instrument choice, cardinality, transport, and cost together.
- Prometheus client instrumentation in Python — the pull-model implementation in full, including registries, multiprocess mode, and framework hooks.
- The OpenTelemetry metrics SDK in Python — the push-model implementation: provider lifecycle, views, temporality, and OTLP export.
- Metric types and cardinality — choosing the right instrument and bounding its label set, which matters equally on both transports.
- Exporting OTLP metrics to the Collector — the export hop in detail if you take the push path.
- Distributed tracing and OpenTelemetry in Python — the signal that shares the Resource, exporter, and Collector when you unify on OTLP.
- Bridging Prometheus metrics into OpenTelemetry — the two migration paths, the name translation, and the order the phases have to happen in.
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.