Practical Postgres ๐Ÿ˜ CDC with StreamkapLivestream July 15

Skip to main content

Alert on Stale Tables From a Flight

Build this flight plan with your AI assistant

I want scheduled freshness monitoring on my MotherDuck tables with dbt-style warn/error age thresholds and a Slack alert when data goes stale. Help me adapt the "Alert on Stale Tables From a Flight" recipe to my own data and use case, using it as a guide: https://motherduck.com/docs/cookbook/flight-freshness-alert

A single-file Flight that monitors how fresh your MotherDuck tables are and posts a Slack alert when one falls behind. It follows the dbt source-freshness model: each table names a timestamp column, and you compare now() - MAX(column) against a warn and an error threshold. The Flight schedules the check, writes an audit ledger, and pings Slack when something is stale.

Unlike the config-driven templates here, the thing you edit is a CHECKS list at the top of flight.py (much like you replace repo_rows() in flight-dlt-ingest). The defaults point at read-only sample_data, a frozen ~2022 snapshot, so a fresh deploy always reports error and fires one Slack message. That doubles as a built-in test that your webhook is wired up. Swap CHECKS for your own tables before you add a schedule.

How it worksโ€‹

flight.py runs a fixed sequence; only the CHECKS list changes its inputs:

  1. Connect to MotherDuck (md:).
  2. For each check, run SELECT max(column), date_diff('hour', max(column), now()) and assign pass / warn / error from the two thresholds. The comparison is done in SQL against the runtime clock, so it follows the runtime timezone (see Time zones). A missing table/column or an empty table is recorded as error for that one check, not raised, so a single typo does not hide the other checks.
  3. Write one ledger row per check to RESULTS_TABLE (its database and schema are created on first run, since sample_data is read-only).
  4. Print the report. If any check is at or above ALERT_LEVEL and SLACK_WEBHOOK_URL is set, POST a Slack Block Kit message listing each stale table with its lag and severity.

Time zonesโ€‹

Freshness is now() - MAX(column), computed in the database, so the comparison uses the runtime's clock and timezone. A deployed Flight runs in UTC; a local uv run uses your machine's timezone. Two consequences:

  • TIMESTAMPTZ columns are unambiguous. They carry an offset, so the age and the stored max_timestamp are correct no matter where the code runs. Prefer these.
  • Naive TIMESTAMP columns are read in the runtime timezone. A value like 2024-01-01 09:00:00 with no offset is treated as 09:00 in the runtime tz (UTC on a Flight). If your naive timestamps are actually stored in another timezone, the computed age is off by that UTC offset, and the max_timestamp written to the ledger reflects the runtime tz. Deploy as a Flight (UTC) for consistent results, or store source timestamps as TIMESTAMPTZ.

Why pytz is a dependency. Reading a TIMESTAMPTZ value back into Python as a tz-aware datetime requires pytz. The duckdb wheel ships with no required dependencies (timezone math inside the engine uses the bundled ICU extension), so pytz is not installed automatically; without it, a check on a TIMESTAMPTZ column fails with Required module 'pytz' failed to import. It is pinned next to duckdb in requirements.txt so checks work on tz-aware columns.

Questions to answerโ€‹

  • Which tables need monitoring, and which column on each records when its rows arrive (load time, event time, ingest time)?
  • What warn/error age thresholds match each table's expected update cadence?
  • Should the alert fire on warn, or only on error?
  • Which Slack channel receives the alert, and is its Incoming Webhook stored as a MotherDuck secret?
  • Which service account token can read the checked tables and write the ledger?
  • What schedule (cron, UTC) matches how often the data should refresh?

Caveatsโ€‹

  • The default CHECKS always alert. sample_data is a read-only ~2022 snapshot, so both default checks report error every run. This is intentional: it proves a fresh deploy can reach Slack. Replace CHECKS with your own tables before adding a schedule, or you will get a Slack message on every run.
  • Pick the right column. Freshness is MAX(column). Use the column that records when a row landed (load, ingest, or event time), not an unrelated date.
  • Failures degrade per check. A missing table/column or an empty table is recorded as error for that check with the reason, rather than aborting the run.
  • A broken webhook fails the run. A configured SLACK_WEBHOOK_URL that returns an error raises after the ledger is written, so a broken alert path shows up as a FAILED run instead of silently dropping the alert.
  • The ledger needs a writable database. RESULTS_TABLE must live in a database you can write to; the Flight creates flights_demo if it is missing. Set RESULTS_TABLE = "" to skip the ledger.
  • Keep secrets out of code. Put the webhook in a MotherDuck secret and select a token on the Flight; never hard-code either.

What you'll adjustโ€‹

The freshness checks live in the CHECKS list at the top of flight.py; two module constants below it control alerting and the ledger. Only the Slack webhook and the MotherDuck token come from outside the code.

KnobWhereDefaultPurpose
CHECKStop of flight.pytwo sample_data tablesThe list of freshness checks. Replace these entries with your own tables.
table (per check)CHECKS entrysample_data.hn.hacker_news, sample_data.nyc.taxiTable to check, as database.schema.table or schema.table. Each part validated as a SQL identifier.
column (per check)CHECKS entrytimestamp, tpep_pickup_datetimeThe timestamp/date column whose MAX defines freshness. Use the column that records when a row arrived.
warn_after_hours / error_after_hoursCHECKS entry24 / 48Age thresholds in hours. lag >= error โ†’ error, >= warn โ†’ warn, else pass.
ALERT_LEVELtop of flight.pywarnwarn alerts on warn+error; error alerts only on error.
RESULTS_TABLEtop of flight.pyflights_demo.main.freshness_check_runsAudit ledger target as database.schema.table. Must be a writable database. "" disables the ledger.
SLACK_WEBHOOK_URLFlight secret / env var(unset)Slack Incoming Webhook URL. Provide it through a MotherDuck secret, never in code. As a Flight the secret arrives as <secret_name>_SLACK_WEBHOOK_URL; flight.py resolves either name. Unset โ†’ the run prints the report and skips Slack.
MOTHERDUCK_TOKENFlight-injected(Flight-injected)Auth. Select a token on the Flight; never hard-code it.

Run itโ€‹

You need a MotherDuck account and an access token. With the defaults, the check reads two public sample_data tables (no extra credentials needed). Provide SLACK_WEBHOOK_URL only if you want a real Slack post during the smoke test (see Create the Slack webhook below to get one).

export MOTHERDUCK_TOKEN=your_token_here
# optional: actually post to Slack instead of only printing
export SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
uv run --with-requirements requirements.txt flight.py

With the defaults this checks the two frozen sample_data tables, prints two error lines, writes the ledger to flights_demo.main.freshness_check_runs, and (if SLACK_WEBHOOK_URL is set) posts one Slack alert. That confirms the whole path before you point CHECKS at your own tables.

Create the slack webhookโ€‹

Skip this if you only want the printed report. To post to Slack, create one Incoming Webhook and reuse its URL (see the Slack incoming webhooks docs):

  1. Open api.slack.com/apps?new_app=1 and choose From a manifest.
  2. Select your workspace.
  3. Paste this manifest (it only sets the app name; rename it if you like), then review and create the app:
   {
"display_information": {
"name": "Fresh Ducks"
},
"settings": {
"org_deploy_enabled": false,
"socket_mode_enabled": false,
"is_hosted": false,
"token_rotation_enabled": false
}
}
  1. Open Incoming Webhooks in the app settings (https://api.slack.com/apps/<APP_ID>/incoming-webhooks), toggle Activate Incoming Webhooks on, click Add New Webhook to Workspace, pick the destination channel, and Authorize.
  2. Copy the generated webhook URL (it looks like https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXX) and use it as SLACK_WEBHOOK_URL: an env var for a local run, or the secret below for a Flight.

The webhook URL is a secret and is tied to the one channel you picked. Keep it out of code and version control (store it as a MotherDuck secret for a Flight); Slack revokes webhook URLs that leak.

Deploy as a Flightโ€‹

Store the webhook URL from Create the Slack webhook as a MotherDuck Flights secret. The simplest way is the MotherDuck UI: open Settings > Secrets, add a secret of type Flights, and give it a SLACK_WEBHOOK_URL parameter. If you would rather use SQL, you can create the same secret from the DuckDB client or any write-enabled SQL connection (read-only connections reject CREATE SECRET):

CREATE SECRET freshness_slack IN motherduck (
TYPE flights,
PARAMS MAP { 'SLACK_WEBHOOK_URL': 'https://hooks.slack.com/services/...' }
);

A TYPE flights secret injects each param under the env var <secret_name>_<PARAM>, not the bare param name: the param above arrives as freshness_slack_SLACK_WEBHOOK_URL, not SLACK_WEBHOOK_URL. (DuckDB lowercases the unquoted secret name into the prefix.) flight.py handles this: it reads SLACK_WEBHOOK_URL for local runs and otherwise picks up any env var ending in _SLACK_WEBHOOK_URL, so the secret name you choose does not matter.

Then create the Flight with the MD_CREATE_FLIGHT SQL function (no deploy SQL is checked in; adapt the arguments to your situation), passing:

  • name: a Flight name, for example freshness_alert
  • source_code: the contents of flight.py, with CHECKS edited to your tables
  • requirements_txt: the contents of requirements.txt
  • flight_secret_names: ["freshness_slack"] so the webhook is injected (as freshness_slack_SLACK_WEBHOOK_URL; flight.py resolves it)

No config is needed: every knob lives in the code. A MotherDuck token is attached to the Flight automatically and injected at run time as MOTHERDUCK_TOKEN; no token argument is needed.

Create the Flight without a schedule first, trigger one manual run with MD_RUN_FLIGHT(flight_id := ...) (the id is returned by MD_CREATE_FLIGHT and listed by MD_FLIGHTS()), and confirm it succeeds and a Slack alert arrives. Then edit CHECKS to your real tables and add a schedule (for example 0 * * * *, hourly) by updating the Flight's schedule_cron with MD_UPDATE_FLIGHT. Schedule updates are metadata-only and do not create a new Flight version.

Securityโ€‹

  • Identifier validation. Each CHECKS table (split on .) and column, and RESULTS_TABLE, are checked against ^[A-Za-z_][A-Za-z0-9_]*$ before any SQL runs, because they flow into CREATE/SELECT/INSERT statements that cannot be parameterized.
  • Parameterized data. Ledger rows (table name, column, timestamps, lag, status, detail) are written with bound parameters, never string-formatted into SQL.
  • Secret-based webhook. SLACK_WEBHOOK_URL is read from a MotherDuck secret or env var at runtime, never hard-coded or placed in Flight config.

Learn moreโ€‹

  • Flight mechanics (creating, running, scheduling): use the MotherDuck MCP get_flight_guide tool.
  • Slack delivery: Incoming Webhooks and Block Kit for the message format.
  • Deeper MotherDuck or DuckDB questions: use the ask_docs_question MCP tool.
  • Files in this template: flight.py (the single-file Flight source) and requirements.txt (duckdb, httpx, pytz โ€” see Time zones).