/blog/why-is-my-python-app-slow
8 causes, ordered
free plan / 1 gb-mo
telemtra.io
// performance debugging

Why is my Python app slow?
A debugging checklist.

Eight production-grade causes of Python latency, ordered by how often they turn out to be the answer. Each one paired with the OpenTelemetry signal that would have caught it.

Python production performance follows a different pattern than Node or Go. The GIL, the ORM, the sync-into-async traps, and the cold-start cost of large packages each cause distinct slowdowns. This is the checklist we'd actually run, in order, with the OpenTelemetry instrumentation that turns most of these from guesses into measurements.

1. A downstream call is the real bottleneck

Same first move as every language. Half the time "Python is slow" means "the response is slow because we're waiting on Postgres / Redis / a downstream service." Resist the urge to optimise Python before confirming Python is what's slow.

OTel signal: trace waterfall. The long span is almost always an outbound HTTP or DB span, not application code. Auto-instrumentation via opentelemetry-instrumentation-requests, -psycopg2, -redis, etc. produces them for free.

2. ORM N+1 queries (Django / SQLAlchemy)

The most reliable Python slowness cause. Order.objects.all() followed by for o in orders: o.customer.name issues one query per order. SQLAlchemy's lazy-loaded relationships do the same. The endpoint is fine on a dev database with 10 rows and falls over in production at 1,000.

OTel signal: count of DB spans per trace. Anything above 10 is suspect; above 50 is the cause. db.statement attribute reveals the repeated query pattern. Fix with select_related / prefetch_related (Django) or joinedload (SQLAlchemy).

3. Sync code in an async handler

FastAPI, Starlette, aiohttp let you write async endpoints. The trap: making a sync call (a sync database driver, a sync HTTP client, a CPU-heavy operation) inside an async handler blocks the event loop the same way it blocks Node. The whole worker stalls for every request that's currently in-flight.

OTel signal: event loop delay metrics from opentelemetry-instrumentation-asyncio. Latency spikes that correlate across different endpoints in the same worker process are the giveaway. Fix with the async-native driver (asyncpg instead of psycopg, httpx instead of requests) or by offloading sync work to a thread pool.

4. The GIL is the bottleneck on CPU-bound work

The Global Interpreter Lock serialises Python bytecode execution. CPU-bound work in pure Python doesn't scale across cores within a process; you need multiprocessing, not threads. Symptoms: latency climbs as load increases, CPU utilisation per core stays at 100% of one core regardless of how many cores are on the machine.

OTel signal: CPU runtime metrics (process.cpu.utilization) versus thread count and request rate. If utilisation is pinned around 1 / (number of cores) and threads aren't helping, GIL is the limit. Fix with multiprocessing workers (gunicorn workers, multiple uvicorn processes) or move CPU work to C extensions (numpy, polars, native libs).

5. Cold imports on first request

Python imports are slow. A web framework with 100+ dependencies can take 5+ seconds to import on first start. If your app uses lazy imports (or runs serverless), the first request after deploy or after a function cold start carries the import time.

OTel signal: first-request latency by service.instance.id after deploy. If only the first request to a fresh instance is slow, cold imports. Fix with eager imports at module top or pre-warming probes.

6. Memory pressure forcing GC

Python's reference-counting GC is fast, but large object cycles trigger the cycle-detection GC, which is not. Long-lived references (caches, closures over request data) push memory up; eventually the cycle collector runs and the process pauses.

OTel signal: process.runtime.cpython.memory (or process.memory.usage) growing without bound, and gc-stat metrics if you instrument them via a manual exporter. Sharp memory growth + intermittent latency spikes is the pattern.

7. Logging overhead on hot paths

The Python logging module is not free. A logger writing structured JSON at DEBUG level on every request line can take more CPU than the business logic. Especially under logger.debug(f"data: {expensive_repr(obj)}") where the f-string runs even when DEBUG is disabled at the handler level.

OTel signal: log record count per second from the OTel log bridge, charted against latency. Spike correlation is the smoking gun. Fix with lazy formatting (logger.debug("data: %s", obj)) and setting production log level to INFO.

8. Connection pool too small

Django's CONN_MAX_AGE, SQLAlchemy's pool_size, redis-py's connection_pool. Each defaults to a small number. Under load, requests queue on the pool, and the wait shows up as database latency from the application's perspective.

OTel signal: the gap between "time waiting for connection" (which OTel database instrumentation increasingly exposes as a separate span event) and "actual query time." Or, more simply: latency that scales with concurrent request count rather than with query complexity. Fix is pool sizing, but verify with metrics before guessing.

The minimum instrumentation to get all of this

Three commands, no code change:

pip install opentelemetry-distro \
            opentelemetry-exporter-otlp-proto-http \
            opentelemetry-instrumentation-system-metrics

opentelemetry-bootstrap -a install

export OTEL_SERVICE_NAME=checkout-api
export OTEL_EXPORTER_OTLP_ENDPOINT=https://ingest.telemtra.io
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_HEADERS="x-api-key=$TELEMTRA_API_KEY"
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment=production"

opentelemetry-instrument python app.py

The opentelemetry-bootstrap step auto-detects installed frameworks (Django, Flask, FastAPI, requests, psycopg, SQLAlchemy, Redis, Celery, more) and installs the matching instrumentation packages. The opentelemetry-instrument launcher wires them up at import time without modifying your code.

A debugging order that usually works

  1. Open a slow trace. If the long span is an outbound HTTP or DB call, jump to that service or run EXPLAIN on the query. You're done.
  2. If the long span is in your code, count DB spans per trace. 50+ on one endpoint means ORM N+1.
  3. If DB span count looks normal, check whether sync calls exist in async handlers. Latency correlated across unrelated endpoints in the same worker is the signal.
  4. If async looks clean and CPU is pinned at 1/cores, GIL bottleneck. Add workers or move work to native code.
  5. If only first-request-per-instance is slow, cold imports. Pre-warm or import eagerly.
  6. None of the above → memory leak, logging overhead, or pool exhaustion. Each has a distinct metric pattern.

Further reading

See OpenTelemetry Python instrumentation for the SDK setup, OpenTelemetry best practices for production for instrumentation hygiene, and Debug 500 errors with distributed tracing for the incident playbook. Start a Telemtra workspace to send these signals to a hosted OTLP backend in under a minute.