Handlers and notify
A handler is a task that runs only when something changed and notified it, and only once, at the end of the play. It is the mechanism that turns "I edited nginx's config" into "therefore reload nginx, exactly once, after all the config edits are done." You define the handler once, notify it from any task that touches its config, and Ansible takes care of running it at the right time and the right number of times.
Handlers are pure Ansible identity. The deferred, change-triggered, run-at-the-end model has no analog in a provisioning tool, and the ways it surprises people are some of the most-asked Ansible questions there are. This topic covers how a handler fires, how notify and listen wire it up, why it runs once at the end, the traps where it quietly does not fire, and the meta: flush_handlers escape hatch.
Deferred, Change-Triggered Execution
A handler lives under a play's handlers: key and does nothing on its own. It runs only when a task notifys it — and the notify itself fires only if that task reported changed. So editing larkspur.conf triggers reload nginx, while a re-run that finds the config already correct triggers nothing at all. This is the core of "reload the service only when its config actually moved."
tasks: - name: Render larkspur.conf ansible.builtin.template: src: larkspur.conf.j2 dest: /etc/nginx/sites-available/larkspur.conf notify: reload nginx handlers: - name: reload nginx ansible.builtin.service: name: nginx state: reloaded
On the first run the template writes the file, reports changed, and the notify queues reload nginx to run at the end of the play. On the second run the file already matches, the template reports ok, no notify fires, and nginx is left alone. The handler never ran on a no-op — which is the whole point.
changednotify: reload nginxnotify and the Handler Name / listen
A task's notify: reload nginx matches a handler by its name:, and the match is literal — the strings must be identical, capitalization included. That exact-match rule is simple but unforgiving: Reload nginx and reload nginx are two different things to Ansible, and a mismatch notifies nothing while reporting no error.
listen: adds a layer of indirection. Several handlers can subscribe to one topic with listen: restart web stack, and a single notify: restart web stack then fans out to all of them. This decouples the notifying task from the exact handler names, so you can add a handler to the topic later without touching every task that notifies it.
Run Once, at the End of the Play
Handlers run after every task in the play has completed, and a handler notified many times runs exactly once. This is why ten config-file edits that all notify: restart gunicorn produce a single gunicorn restart, at the end, instead of ten restarts scattered through the run. The deferral batches the side effect to the one moment it makes sense.
That batching is the behavior you want for service restarts. Restarting gunicorn after each of ten edits would bounce the app ten times and serve errors in between; restarting it once, after all edits land, bounces it cleanly into the final state. The handler model gives you that for free as long as every config task notifies the same handler.
The Doesn't-Fire Traps
A handler will not fire if its notifying task reported ok rather than changed — which is correct, and the source of the most common confusion: an idempotent re-run reloads nothing, and that is the feature, not a bug. The sharper trap is the failure window. A notified handler is skipped if a later task in the same play fails before handlers run, because handlers only execute after the task list completes.
That window has teeth. Suppose render larkspur.conf notifies reload nginx, then a later task fails — the run stops before the end-of-play handler phase, and reload nginx is dropped. The new config is on disk while nginx still serves the old one. Rerun the playbook, or run with --force-handlers to run pending handlers even after a failure, but know the gap exists.
meta: flush_handlers
Sometimes a later task in the same play needs the service already reloaded — it cannot wait for the end-of-play handler phase. - meta: flush_handlers forces every pending handler to run immediately at that point in the task list, then execution continues. You place it exactly where the dependency is: after the config edits, before the task that needs the reloaded service.
tasks: - name: Render larkspur.conf ansible.builtin.template: src: larkspur.conf.j2 dest: /etc/nginx/sites-available/larkspur.conf notify: reload nginx # run reload nginx now, not at end of play - meta: flush_handlers - name: Smoke-test the reloaded nginx ansible.builtin.uri: url: http://localhost/healthz
Without the flush, the smoke test would hit nginx still running the old config, because the reload was queued for the end of the play. With it, the reload happens first and the test checks the real, reloaded state. flush_handlers is the tool for any "I need this handler to have already run" moment inside a play.
- Expecting
reload nginxafter every run and being confused when an idempotent re-run reloads nothing — the handler only fires when the config task reportschanged, which is the intended behavior. - A task later in the play failing before handlers run, so the notified
restart gunicornis skipped and the new code sits on disk while the old process keeps serving it — rerun or use--force-handlers. - Naming a handler
Reload nginxand notifyingreload nginx— the match is exact and case-sensitive, so a capitalization mismatch silently notifies nothing. - Putting handlers in one play and notifying them from another — handlers are play-scoped, so a
notifycan only reach handlers defined in the same play. - Needing a service reloaded before a subsequent task uses it and waiting for end-of-play handlers, when the right tool is
meta: flush_handlersplaced before that task.
- Drive every service restart or reload through a handler notified by the config task, so services bounce only when their config actually changed.
- Use
listen:topics likerestart web stackwhen several handlers should respond to one event, decoupling notifiers from the exact handler names. - Know the failure window: when a mid-play failure must not strand a pending reload, run with
--force-handlersor insertmeta: flush_handlersbefore the risky task. - Keep handler names stable and exact —
reload nginx,restart gunicorn,restart postgresql— and reuse them across tasks, since the match is by literal name. - Define handlers in the same play as the tasks that notify them, since the notify cannot reach across a play boundary.
Knowledge Check
Under what condition does a notified handler actually run?
- Only when the notifying task reported
changed— a task that reportedokfires no notify and the handler stays idle - Every time the play runs to completion, regardless of whether the notifying task changed anything — the handler block fires once per run as a guaranteed end-of-play step
- Only when you also pass
--force-handlerson theansible-playbookcommand line - Immediately at the point of the
notifyline, before the next task in the play runs
A task notifies reload nginx and reports changed, but a later task in the same play fails before the handler phase. What happens to the reload?
- It is skipped — handlers run only after the task list completes, so the failure leaves the reload pending and the old config live
- It runs immediately at the moment the later task fails, fired as an automatic cleanup step that reloads nginx into the new config just before the play aborts on the error
- It still runs at the end, because once a handler is notified it always fires no matter what follows
- Ansible rolls back the config change that triggered the notify and restores the previous file
Ten tasks in a play each notify restart gunicorn and all report changed. How many times does gunicorn restart?
- Once, at the end of the play — a handler notified any number of times runs a single time
- Ten times, once for each notifying task that reported
changed, restarting gunicorn in the same order those ten tasks ran through the play - Twice — once partway through mid-play and a second time at the end of the play
- Zero times, because the multiple competing notifies cancel one another out
What does - meta: flush_handlers do, and when do you need it?
- It forces all pending handlers to run immediately at that point, used when a later task depends on the reload having already happened
- It clears the pending handler queue at that point, discarding every notification a changed task had queued, so that none of the notified handlers run at the end of the play
- It re-notifies every handler in the play, regardless of whether any task actually reported a change
- It defers all of the pending handlers until the next time the playbook is run
You got correct