Every Looker deployment eventually grows a second, invisible product: the stuff that leaves Looker. Monday-morning PDF packs, a CSV that lands in an S3 bucket for a partner, a Slack ping when refunds spike, a webhook that pokes a downstream system. Nobody designs this layer. It accretes, one "can you just email me that every day?" at a time, and then someone notices that the warehouse bill has a spike at 06:00 every weekday and that half the alerts fire on stale data.
This tutorial covers Looker's delivery layer end to end: how schedules and alerts actually execute, how to keep them from stampeding your warehouse, how conditional alerts differ from conditional schedules, how to deliver somewhere other than email using the Action Hub, and how to audit the whole estate with System Activity and the API.
The three delivery mechanisms, and when each is correct
Looker gives you three overlapping things. Choosing wrong is the root of most delivery pain.
Schedules send a Look, dashboard, or Explore query on a cadence — every hour, every weekday at 07:00, the first of the month. They run whether or not the numbers are interesting. Use them for reporting rituals: the board pack, the daily ops CSV, the regulatory extract.
Conditional schedules are schedules with a "send only if there are results" or threshold condition attached. The query still runs on the cadence; delivery is suppressed when the condition is not met. Use them for exception reporting where you own the underlying Look — "send me the list of accounts past due, but only if the list is non-empty".
Alerts are attached to a dashboard tile, owned by whoever created them (not necessarily the dashboard owner), and evaluated on their own frequency against a threshold on a specific tile field. Alerts can be followed by other users, which is the important difference: one alert definition, many recipients, one query. Use them for "tell me when X crosses Y".
A useful rule of thumb: if the recipient list will grow, make it an alert. If the artifact matters (a formatted PDF, a fixed-schema CSV), make it a schedule. Recreating the same threshold as fifteen personal schedules is how you get fifteen identical queries firing at the same minute.
Schedules run real queries, and the cache is usually cold
A scheduled delivery is a normal query subject to normal caching rules — with one caveat that surprises people: the schedule's SQL must match a cached result exactly to reuse it, and schedules typically run before anyone else is awake, so they are the ones paying for the cold cache.
That gives you two levers.
Lever one: align schedules with your datagroups. If your nightly load finishes at 05:40 and a datagroup trigger picks it up within five minutes, scheduling the pack for 05:45 means the schedule itself warms the cache and every human who opens the dashboard at 09:00 gets a cache hit. Scheduling it for 03:00 means it reports yesterday's data and the humans pay for a fresh query later. Look at the actual load completion time, not the time in the runbook.
You can go further and make delivery event-driven rather than clock-driven:
datagroup: nightly_etl {
sql_trigger: SELECT MAX(batch_id) FROM etl_metadata.batch_log WHERE status = 'SUCCESS' ;;
max_cache_age: "24 hours"
}
With that datagroup in place, a schedule set to run "when the datagroup triggers" fires after the data lands rather than at a guessed clock time. This is the single highest-value change most Looker estates can make to their delivery layer: it removes the whole category of "the report went out before the data finished loading" incidents.
Lever two: stagger. Looker executes scheduled jobs from a queue with limited concurrency per instance and per connection. Forty schedules all set to 07:00 do not run at 07:00; they run between 07:00 and whenever the queue drains, and the last recipient gets a report timestamped 07:00 that arrived at 07:35. Spread heavy jobs across the hour, and put the heaviest ones first.
Delivery formats have real cost differences
The format you pick changes what runs on the server:
- CSV, JSON, and raw data are close to free beyond the query itself. Note that "results in table" versus "all results" is a common trap — the default may be limited to the visible rows.
- Inline visualisations and PDF/PNG dashboard renders require Looker to spin up a headless browser and render the dashboard, tile by tile, at the schedule's user permissions. A 40-tile dashboard render is dramatically more expensive than the same data as a CSV, and render jobs contend with each other.
- Dashboards deliver as the schedule owner. If the owner's user attributes differ from the recipient's, the recipient sees the owner's row-level slice. This is a genuine data-governance issue in multi-tenant setups: review who owns schedules that leave the building.
If a recipient only ever pastes the numbers into a spreadsheet, send them a CSV and reclaim the render capacity.
Writing alerts that people do not mute
Alerts are evaluated against a tile's field and threshold on a chosen frequency. The failure mode is not technical, it is behavioural: an alert that fires often gets filtered into a folder and stops being an alert.
Practical patterns:
- Alert on a rate or a delta, not a raw count. "Orders below 500 today" fires every holiday. "Orders down more than 40% versus the same weekday last week" fires when something is actually wrong. Build the comparison as a measure in LookML so the alert has a single clean field to watch:
measure: orders_wow_change {
type: number
sql: SAFE_DIVIDE(${count} - ${count_prior_week}, NULLIF(${count_prior_week}, 0)) ;;
value_format_name: percent_1
description: "Week-over-week change in orders; used by the ops alert"
}
- Guard against incomplete data. An alert running hourly on a table that loads once a day will see a partial day and scream. Either restrict the tile to complete periods (
is before today, or a filter on your batch metadata) or attach the alert to a tile whose datagroup guarantees the data is settled. - Use the alert's own cadence deliberately. Hourly evaluation of an expensive Explore is an hourly warehouse query, forever. Match the cadence to how fast the data can actually change.
- Write the message for someone who is not looking at Looker. Include the value, the threshold, and what to do. Alert titles like "Alert on Tile 4" are how alerts get ignored.
- Ownership matters. Alerts belong to their creator. When that person leaves, the alert keeps firing (or silently breaks). Include alert ownership in your offboarding checklist — Looker admins can transfer or disable them.
Beyond email: the Action Hub and webhooks
Email and Slack cover most cases, but Looker's delivery layer is pluggable. Destinations come from three places:
- Looker Actions (the hosted Action Hub) — Slack, Google Sheets, SFTP, Amazon S3, Google Cloud Storage, Segment, Twilio, and more, enabled per-instance under Admin → Actions.
- A webhook — Looker POSTs the results to a URL you provide. The simplest possible integration, and the right choice for "kick off a downstream job when this report is ready".
- A custom action server — your own service implementing the Action API, registered as an action hub. This is how you write results back into an operational system: push a churn-risk list into your CRM, create tickets, update a pricing table.
A custom action server is a small HTTP service exposing a handful of endpoints: /actions/list describing what your actions do and what form fields they need, /actions/<id>/form for dynamic form fields, and /actions/<id>/execute which receives the payload. A minimal listing looks like this:
{
"integrations": [
{
"name": "crm_push",
"label": "Push to CRM",
"supported_action_types": ["query"],
"supported_formats": ["json_detail"],
"supported_formattings": ["unformatted"],
"url": "https://actions.internal.example.com/actions/crm_push/execute",
"form_url": "https://actions.internal.example.com/actions/crm_push/form",
"params": [{ "name": "api_key", "label": "CRM API Key", "sensitive": true }]
}
]
}
Three things to get right before this touches production:
- Authenticate both directions. Looker sends a bearer token you configure; verify it on every request. Your service should also treat the payload as untrusted input.
- Respond fast, work asynchronously. Looker expects a timely response. Acknowledge, queue, and process out of band for anything slow.
- Be idempotent. Schedules retry. A "create ticket" action that is not idempotent will create duplicates the first time your instance has a bad night.
Write-back actions blur the line between BI and operations, which is exactly why they are worth doing — and exactly why they need the same review discipline as any other integration.
Auditing the delivery estate
Most instances have no idea what they are sending. System Activity can tell you in a few minutes.
- The Scheduled Plan Explore lists every schedule, its owner, cadence, destination, format, and the content it points at. Sort by owner and look for people who no longer work there.
- Join to Scheduled Plan Run history to find plans that fail repeatedly. A schedule that has failed every day for six months is either unnecessary or is quietly breaking someone's process.
- The History Explore, filtered to a source of
scheduled_task, shows what schedules actually cost in runtime. Cross-reference the slowest ones with their delivery format; the expensive ones are usually big PDF renders. - The Alerts System Activity model shows alert definitions, followers, and notification history — use it to find alerts with zero followers and alerts that fire almost every evaluation.
The same data is available through the API, which is how you turn this into a recurring hygiene job rather than a one-off spring clean:
import looker_sdk
sdk = looker_sdk.init40()
for plan in sdk.all_scheduled_plans(all_users=True):
dest = ", ".join(d.type for d in (plan.scheduled_plan_destination or []))
print(f"{plan.id}\t{plan.name}\t{plan.user.display_name}\t{plan.crontab}\t{dest}\t{plan.enabled}")
From there it is a short step to policy: disable schedules owned by deactivated users, flag any plan with no successful run in 90 days, and require that new schedules on core dashboards use a datagroup trigger rather than a clock time.
A short checklist
Before a schedule or alert goes live, ask:
- Is this a schedule, a conditional schedule, or an alert? Will the recipient list grow?
- Does it run after the data lands — ideally on a datagroup trigger, not a guessed time?
- Is the format the cheapest one the recipient will actually accept?
- Whose permissions does it deliver under, and is that the right data slice for every recipient?
- If it is an alert, does the threshold survive weekends, holidays, and partial loads?
- Who owns it, and what happens when that person leaves?
Delivery is the part of Looker that your least technical stakeholders see most often. Treating it as designed infrastructure — versioned thresholds in LookML, datagroup-aligned timing, an audited inventory — is usually a faster route to "Looker feels reliable" than any amount of Explore tuning.
Vistelio's senior Looker developers build and audit LookML, delivery, and alerting infrastructure for teams running Looker in production. If your schedule estate has grown past the point where anyone can explain it, get in touch.