Pixel Pete, the CanaryHub canaryCanaryHub

Flask health check: from a bare 200 OK to an endpoint that actually pages you

Flask health check: from a bare 200 OK to an endpoint that actually pages you

Quick answer

Flask has no built-in health check. Add a plain route that returns JSON with a status field, split it into a shallow liveness check and a deeper readiness check that pings your database with a bounded timeout, and return HTTP 503 when something's actually broken. The flask-healthz library gives you that liveness/readiness split out of the box if you'd rather not hand-roll it, but either way the endpoint only matters once an external monitor is watching it.

Flask gives you a Flask object, a router, and jsonify. It does not give you a health check. There's no /health route baked in, no convention Flask itself enforces, nothing in the framework that distinguishes "the process is alive" from "the app can actually do its job." You write the whole thing yourself, which is fine, except most examples online stop at a single route that always returns 200, which checks nothing and catches nothing.

Here's a health check worth having: a shallow liveness route, a deeper readiness route with a bounded database check, the flask-healthz library if you'd rather not hand-roll either, and the external monitor that turns a failing check into something that wakes you up.

Does Flask have a built-in health check?

No. Flask's own API documentation covers the Flask application object, routing, request and response handling, error handlers, and the CLI, and none of it mentions health, readiness, or liveness. If you want a /health endpoint, you're writing a route like any other:

from flask import Flask, jsonify

app = Flask(__name__)

@app.route("/health")
def health():
    return jsonify(status="ok"), 200

That's a real liveness check: it proves the process is up and can handle a request. It's also the entire reason most "Flask health check" guides online stop here. It doesn't prove your app can talk to its database, its cache, or anything else it actually depends on.

What's the difference between a liveness check and a readiness check in Flask?

A liveness check answers "is this process alive and able to respond at all." A readiness check answers "can this process actually serve a real request right now," which usually means its dependencies are reachable too. Keep them as separate routes:

@app.route("/health")
def health():
    # liveness: no dependency calls, just prove the process can respond
    return jsonify(status="ok"), 200


@app.route("/ready")
def ready():
    # readiness: lives below, checks the database
    ...

Keeping liveness dependency-free matters: if your readiness check is the one something restarts you on, and that check hangs on a slow database, you can end up restarting a perfectly healthy process because a dependency was briefly slow. Liveness stays cheap and fast; readiness does the real work.

How do you check a database connection without hanging the endpoint?

The naive version calls your ORM and waits. If the database is unreachable rather than erroring immediately, for example a security group silently dropping the connection, that call can hang far longer than anyone polling /ready is willing to wait. Bound it with a connection timeout instead of trusting the client library's default:

import psycopg2
from flask import Flask, jsonify

app = Flask(__name__)
DATABASE_URL = "postgresql://user:pass@localhost/app"


def db_check():
    try:
        conn = psycopg2.connect(DATABASE_URL, connect_timeout=3)
        conn.close()
        return "ok"
    except Exception:
        return "down"


@app.route("/ready")
def ready():
    checks = [{"name": "database", "status": db_check()}]
    overall = "ok" if all(c["status"] == "ok" for c in checks) else "down"
    code = 200 if overall == "ok" else 503
    return jsonify(status=overall, checks=checks), code

connect_timeout is a standard libpq connection parameter, documented as the "maximum time to wait while connecting, in seconds," and psycopg2 passes any such parameter straight through as a keyword argument. Three seconds is enough to prove the database answers without letting one slow dependency make your whole app look down to a monitor that's waiting on a response.

Should you use flask-healthz instead of hand-rolling it?

If you want the liveness/readiness split without writing the routes yourself, flask-healthz (maintained by Fedora's infrastructure team) does exactly that. Register it as a blueprint or extension, then point it at your own check functions:

from flask import Flask
from flask_healthz import healthz, HealthError

app = Flask(__name__)
app.register_blueprint(healthz, url_prefix="/healthz")


def liveness():
    pass  # return normally for healthy, nothing else required


def readiness():
    try:
        conn = psycopg2.connect(DATABASE_URL, connect_timeout=3)
        conn.close()
    except Exception as e:
        raise HealthError(f"Can't connect to the database: {e}")


app.config["HEALTHZ"] = {
    "live": liveness,
    "ready": readiness,
}

Your check functions return normally when healthy and raise flask_healthz.HealthError when they're not. The library wires that into GET /healthz/live and GET /healthz/ready, each returning a small JSON body: {"status": 200, "title": "OK"} when healthy, and {"status": 503, "title": "<your error message>"} when a check raises HealthError. Either path, hand-rolled or library, ends up in the same place: a route that returns 503 the moment something real is wrong.

Why doesn't a passing health check mean Flask is being monitored?

Because nobody outside your app is reading it yet. The /ready route you just built returns a clean 503 the instant the database goes unreachable, but a 503 that nothing is watching is just a response nobody fetched. The route is correct and still useless until something external polls it on a schedule and tells a human.

That's the whole job of an uptime monitor: hit /ready every 30 to 60 seconds from outside your network, expect a 2xx, and alert the moment it doesn't get one. With CanaryHub that alert lands on Telegram, Slack, Discord, or email, with the failing check named if you're returning the {status, checks} shape above, so the message says what broke instead of just that something did. The endpoint does the detecting; the monitor does the telling. Skip either half and you're back to finding out from a user.

How do you catch a Celery or APScheduler job that dies silently?

Here's the blind spot every /health or /ready route shares: they only answer when something asks. A Celery worker, a schedule-based script, or an APScheduler job that's supposed to run every few minutes isn't behind any route at all. If it crashes, hangs, or the process running it never comes back after a deploy, /ready keeps returning 200 right up until someone notices the work stopped happening, because nothing about that failure touches your web process.

The fix is a heartbeat: have the scheduled job report in after it finishes, and alert on the absence of that report rather than waiting for an error.

# at the end of the scheduled task, after the real work succeeded
curl -fsS -m 10 --retry 3 https://ingest.canaryhub.ai/ping/YOUR_TOKEN

Configure the monitor with the interval you expect the job to run on, plus a grace period for normal variance. As long as the pings land on time, it stays quiet. The moment one goes missing, it assumes the job died and alerts you, silence itself being the failure signal, which is the only thing that works when the failure mode is "nothing happened" rather than "something errored."

The whole picture

A Flask health check worth having is three layers, each covering the last one's blind spot:

  1. A liveness route (/health) that proves the process can respond, with no dependency calls to slow it down or false-positive it.
  2. A readiness route (/ready, or flask-healthz's /healthz/ready) with a bounded database check returning {status, checks} JSON and a real 503 on failure.
  3. An external monitor on that readiness URL, plus a heartbeat on anything that runs on a schedule outside the request cycle, so a human gets paged for both "the app can't reach its database" and "the job that was supposed to run at 3am didn't."

Get all three in place and a dead database connection or a silently stuck Celery worker becomes a Telegram message you can act on, not a support ticket you read over breakfast. If scheduled jobs are where your actual risk lives, the same heartbeat pattern is worth a closer look in our guide to monitoring a cron job with a dead man's switch.

Frequently asked questions

What should a Flask health check endpoint return?
At minimum, a JSON body with a status field and the matching HTTP code: 200 for healthy, 503 when something is actually down. If you check dependencies, add a checks array naming each one, so the response tells you what broke, not just that something did.
Is /health or /healthz the right path for a Flask app?
Either works. /healthz is the Kubernetes-era convention, and it's what flask-healthz registers by default. /health reads more naturally in app code and is what most external uptime monitors expect out of the box. Pick one and stay consistent across your routes.
Can I just run Flask's built-in dev server behind my health check in production?
No. Flask's own docs say it plainly: the development server 'is not designed to be particularly secure, stable, or efficient,' and is intended only for local development. Run behind gunicorn, uWSGI, or waitress for anything an external monitor is going to poll.
How do I catch a Celery or APScheduler job that dies silently?
You can't, not with a /health route. Nothing calls that endpoint unless the worker process is still alive to be asked. Have the scheduled job ping an external heartbeat URL after it finishes; if the ping doesn't arrive within the expected interval, you get alerted even though no request ever failed.