FastAPI

FastAPI Health Checks & Prometheus Metrics (Liveness, Readiness & Observability)

Thirdy Gayares
12 min read

šŸŽÆ What You Will Learn

Without a health endpoint, your orchestrator can't tell a hung app from a healthy one. Without metrics, you find out about an outage from users. We'll fix both: proper liveness/readiness checks and Prometheus metrics that make your API observable.

  • Why "no health check" means killed pods and silent outages
  • Liveness vs readiness — the difference that trips everyone up
  • A fast /health and a dependency-checking /health/ready
  • Exposing Prometheus /metrics (the RED method)
  • Adding your own custom business metrics
  • Wiring Kubernetes probes and a Prometheus scrape

Prerequisites: a FastAPI app with a database (how-to-connect-fastapi-to-postgres). This is the metrics half of observability — pair it with structured logs from fastapi-logging.

fastapi-health-metrics/
ā”œā”€ā”€ requirements.txt
└── app/
    ā”œā”€ā”€ __init__.py
    ā”œā”€ā”€ database.py         # get_session
    ā”œā”€ā”€ metrics.py          # custom Prometheus metrics
    └── main.py             # /health, /health/ready, /metrics

The Problem: Flying Blind (See It First)

Ship a FastAPI app with no health endpoint and no metrics, and you've created two blind spots at once:

No health endpoint:
  Kubernetes/Docker: "is this container alive? ...I'll just assume yes."
  → a hung app keeps receiving traffic; a slow-starting app gets traffic
    before it's ready → users hit errors

No metrics:
  "Is the API slow right now? How many errors in the last 5 min?"
  → you have no idea until a user complains. You're debugging from logs
    alone, after the fact.
āš ļø
Orchestrators need a signal. Kubernetes, ECS, and even a plain load balancer decide whether to send traffic to your app based on a health check. No endpoint = they either guess (and route to broken pods) or you configure nothing and lose auto-recovery entirely. Metrics are the other half: you can't fix what you can't see.

Liveness vs Readiness (Don't Mix Them Up)

These are two different questions, and conflating them causes real outages. Liveness asks "is the process alive?" Readiness asks "can it serve traffic right now?"

LivenessReadiness
QuestionIs the process alive?Can it serve requests now?
Checks dependencies (DB)?āŒ Noāœ… Yes
Fails → orchestrator...restarts the podstops routing traffic (no restart)
Should beDumb & instantChecks critical deps
Endpoint/health/health/ready
āš ļø
The classic mistake: checking the database in your liveness probe. When the DB has a blip, liveness fails, Kubernetes restarts every pod — turning a brief DB hiccup into a full-service crash loop. Dependency checks belong in readiness, which only pauses traffic and recovers on its own.

Liveness: A Dumb, Fast /health

Liveness should do nothing but confirm the app can respond. No DB, no external calls — just proof the event loop is running. Keep it trivial so it's fast and never falsely fails.

app/main.py
from fastapi import FastAPI

app = FastAPI(title="FastAPI Health & Metrics", version="0.1.0")


@app.get("/health", tags=["health"])
def liveness():
    # if this returns, the process is alive. That's the whole job.
    return {"status": "ok"}
šŸ’”
Ang importante dito: resist the urge to "make it useful" by checking things here. A liveness probe that touches a dependency is a liability. Its only correct failure mode is "the Python process is wedged" — in which case a restart genuinely helps.

Readiness: Check Critical Dependencies

Readiness is where you verify the app can actually do its job — most importantly, that it can reach the database. If a dependency is down, return 503 so the orchestrator stops routing traffic until it recovers.

app/main.py
from typing import Annotated

from fastapi import Depends, HTTPException, status
from sqlalchemy import text
from sqlmodel import Session

from app.database import get_session

SessionDep = Annotated[Session, Depends(get_session)]


@app.get("/health/ready", tags=["health"])
def readiness(session: SessionDep):
    try:
        session.execute(text("SELECT 1"))          # can we reach the DB?
    except Exception:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail="database unavailable",
        )
    return {"status": "ready"}
āš ļø
Keep readiness checks cheap and to the point. Check only critical dependencies you can't serve without (the primary DB), with a short timeout. Don't ping every third-party API — a non-critical service being slow shouldn't pull your whole app out of rotation.

Metrics 101: Measure the Right Things (RED)

Before adding metrics, know what matters. The RED method is the standard starting point for a request-driven service — three signals that tell you almost everything about its health:

SignalQuestion it answersPrometheus type
RateHow many requests per second?Counter
ErrorsHow many are failing (5xx)?Counter (by status)
DurationHow long do they take (p95/p99)?Histogram
šŸ’”
Prometheus is a pull system: your app exposes a /metrics endpoint in a text format, and a Prometheus server scrapes it on an interval. You don't push anywhere — you just expose the current numbers and let Prometheus collect them.

Exposing Prometheus Metrics

The prometheus-fastapi-instrumentator library gives you all the RED metrics — request count, status codes, and a latency histogram — and a /metrics endpoint in two lines. It hooks into the request cycle for you.

app/main.py
from prometheus_fastapi_instrumentator import Instrumentator

# after all routes are defined:
Instrumentator().instrument(app).expose(app)   # adds GET /metrics

Hit /metrics and you get the standard Prometheus exposition format:

$ curl -s http://127.0.0.1:8000/metrics | grep http_request

# HELP http_requests_total Total number of requests by method, status and handler.
http_requests_total{handler="/health",method="GET",status="2xx"} 42.0
http_requests_total{handler="/users",method="POST",status="2xx"} 7.0
# HELP http_request_duration_seconds Duration of HTTP requests in seconds.
http_request_duration_seconds_bucket{handler="/users",le="0.1"} 6.0
http_request_duration_seconds_bucket{handler="/users",le="0.5"} 7.0
āœ…
Instant observability: every endpoint is now counted, timed, and bucketed by status — no manual instrumentation. Point Prometheus at /metrics and you can graph request rate, error rate, and p95 latency for the whole API.

Custom Business Metrics

HTTP metrics tell you the API is healthy; business metrics tell you the product is. Add your own with prometheus-client (already installed as a dependency). A Counter for signups, a Gauge for something you can go up and down — they show up on the same /metrics.

app/metrics.py
from prometheus_client import Counter, Gauge

# name convention: app_<thing>_total for counters
signups_total = Counter(
    "app_signups_total",
    "Total number of successful signups",
)

active_websocket_connections = Gauge(
    "app_active_websocket_connections",
    "Currently open WebSocket connections",
)
app/main.py
from app.metrics import signups_total


@app.post("/signup", status_code=status.HTTP_201_CREATED)
def signup(session: SessionDep):
    # ...create the user...
    signups_total.inc()          # count a real business event
    return {"status": "created"}
$ curl -s http://127.0.0.1:8000/metrics | grep app_signups
# HELP app_signups_total Total number of successful signups
# TYPE app_signups_total counter
app_signups_total 3.0
šŸ’”
Counters only go up (use them for totals like signups, orders, errors). Gauges go up and down (active connections, queue depth). Histograms measure distributions (durations, sizes). Pick the type by how the number behaves.

Try It

requirements.txt
fastapi==0.116.1
uvicorn[standard]==0.30.6
sqlmodel==0.0.22
psycopg[binary]==3.2.1
prometheus-fastapi-instrumentator==7.0.0
1Run the app
run.sh
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload
2Check the three endpoints
# liveness — always fast, no deps
$ curl -s http://127.0.0.1:8000/health
{"status":"ok"}

# readiness — checks the DB
$ curl -s http://127.0.0.1:8000/health/ready
{"status":"ready"}

# stop Postgres, then readiness fails (liveness stays OK!)
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/health/ready
503

# metrics
$ curl -s http://127.0.0.1:8000/metrics | head -n 5
āœ…
You did it: liveness and readiness now report independently — a DB outage takes the app out of rotation without triggering restarts — and every request is measured on /metrics for Prometheus to scrape.

Common Mistakes (and Fixes)

MistakeSymptomFix
Checking the DB in livenessDB blip restarts every pod (crash loop)Deps go in readiness only
One /health for both probesCan't distinguish alive vs readySeparate /health and /health/ready
Readiness pings every external APIOne slow 3rd-party pulls you offlineCheck only critical deps, with a timeout
Rate-limiting the health endpointsProbes get 429, app marked unhealthyExempt them (fastapi-rate-limiting)
/metrics public on the internetLeaks internal traffic patternsRestrict to internal network / scraper
Metric labels with unbounded valuesCardinality explosion, Prometheus OOMNever label by user id, path params, etc.

Production Notes

Wire the probes in Kubernetes so each does its job:

deployment.yaml (probes)
livenessProbe:
  httpGet:
    path: /health
    port: 8000
  initialDelaySeconds: 5
  periodSeconds: 10

readinessProbe:
  httpGet:
    path: /health/ready
    port: 8000
  initialDelaySeconds: 5
  periodSeconds: 5

And point Prometheus at the app:

prometheus.yml (scrape config)
scrape_configs:
  - job_name: fastapi
    metrics_path: /metrics
    static_configs:
      - targets: ["fastapi:8000"]
  • Beware metric cardinality. Never put high-variety values (user id, order id, raw path) in labels — each combination is a new time series and will melt Prometheus. The instrumentator groups by route template (/users/{id}), not the actual id, on purpose.
  • Protect /metrics. Keep it on an internal network or behind auth — it reveals traffic and error patterns you don't want public.
  • Metrics + logs + traces. Metrics tell you something is wrong; structured logs (fastapi-logging) tell you what. Use them together.

What's Next

āœ…
šŸš€ Recommended next reads:
  • fastapi-logging — the structured-logs half of observability
  • fastapi-docker — where the health checks get wired into the container
  • fastapi-github-actions — CI that ships the observable image
  • fastapi-deployment — run it behind an orchestrator that uses these probes

Recap: give your app a dumb-and-fast /health for liveness and a dependency-checking /health/ready for readiness — never check the DB in liveness. Expose Prometheus /metrics for the RED signals, add custom counters for business events, and watch your cardinality. Now your orchestrator can heal the app, and you can actually see how it's doing.

fastapi-health-metrics/             # āœ… finished
ā”œā”€ā”€ requirements.txt                # + prometheus-fastapi-instrumentator
└── app/
    ā”œā”€ā”€ database.py         # get_session
    ā”œā”€ā”€ metrics.py          # custom Counter/Gauge
    └── main.py             # /health (liveness), /health/ready, /metrics

About the Author

TG

Thirdy Gayares

Passionate developer creating custom solutions for everyone. I specialize in building user-friendly tools that solve real-world problems while maintaining the highest standards of security and privacy.