Skip to content
Shell & TerminalDeep Dive Published Updated 8 min readViews unavailable

Zsh Hook Functions: Ordering, Status, and Prompt-Time Work

Manage Zsh precmd, preexec, chpwd, periodic, and history hooks without losing status, slowing prompts, or depending on plugin load order.

Zsh hook functions run at defined points in the interactive shell lifecycle: before a prompt, before a command executes, after a directory change, or when a history line is accepted. They are a useful extension mechanism for prompt state, directory tracking, command timing, and history policy. They are also part of the user’s critical interaction path. A slow or failing hook can delay every prompt, hide the status of the last command, or prevent another hook from running.

Zsh supports a function named for a hook and an array with the same name plus _functions. Functions in that array run after the base hook function, in array order, in the same context and with the same arguments and initial $?. A function can be added or removed with the standard add-zsh-hook helper. Prefer these arrays over repeatedly replacing a shared function name in plugin files.

Choose the lifecycle hook that matches the event

precmd runs before each prompt is displayed. Use it for short prompt-state updates such as refreshing a cached Git branch name or rendering the exit status. A redraw caused by an exiting-job notification does not by itself rerun precmd, so do not rely on it as a general terminal output callback.

preexec runs after an interactive command has been read and immediately before execution. With history enabled, its first argument is the text the user typed; its second argument is a size-limited one-line representation of the command that will execute; and its third argument contains the full text being executed. The second and third forms can include expanded aliases or multi-line constructs. Treat them as observations, not as a safe substitute for a shell parser.

chpwd runs when the current working directory changes. It is a useful point to update local directory context, but not a guarantee that the user changed directories through one specific command: shell startup, functions, plugins, and other builtins can also change the working directory.

periodic runs before a prompt at intervals controlled by the PERIOD parameter. If several functions are registered in periodic_functions, Zsh applies one interval to the set and calls them together. Adding or removing a hook does not reset the scheduled time. For independent polling schedules, use a proper event loop or timer mechanism rather than assuming each periodic function has its own clock.

zshaddhistory runs after an interactive history line is read but before it executes. It receives the complete line, including a terminating newline where present. A nonzero return can affect whether the line is saved: status 1 (and other nonzero statuses except the documented special case) prevents saving while leaving the line available in the in-memory history; status 2 keeps it in the internal list but not the history file. Verify these semantics against the Zsh version in use before implementing a retention policy.

Register hooks without replacing other owners

Load the helper and add a uniquely named function to the relevant hook array:

autoload -Uz add-zsh-hook

my_precmd_status() {
	local last_status=$?
	# Update prompt-owned state without running a slow external command.
	typeset -g PROMPT_LAST_STATUS=$last_status
}

add-zsh-hook precmd my_precmd_status

add-zsh-hook manages the conventional hook arrays and avoids overwriting a plugin’s precmd implementation. Choose a function name that is unlikely to collide, and make it safe to source the configuration more than once. Inspect the resulting array or use add-zsh-hook -L where supported to confirm registration. Remove a function by name when unloading a plugin rather than resetting the entire hook array.

Hooks are ordered shared state. A plugin can add a hook before another plugin is loaded, and the order may affect behavior. Document dependencies explicitly: if one hook computes data consumed by another, ensure the registration order is deterministic. Do not rely on alphabetical function names or incidental file sourcing order unless the configuration explicitly establishes it.

If a base function with the hook’s name is defined, Zsh runs that function before functions in the matching array. Preserve its behavior when wrapping it. A plugin that assigns precmd_functions=(...) can erase other plugins’ entries; append or use the helper instead. In a large configuration, centralize hook ownership and expose add/remove functions to plugins rather than letting every file mutate arrays directly.

Preserve command status and execution context

$? is ephemeral: the first command inside a hook can replace the status the user expects to see. Capture it before running a command substitution, test, or external program. Avoid leaking a hook’s own exit code into a prompt indicator that is meant to describe the previous command. If a prompt theme owns status rendering, pass state through a documented variable or its supported hook rather than independently recomputing it in several functions.

Hooks execute in the shell context, not an isolated worker. Variable assignments, directory changes, options, traps, and aliases can affect later commands. Use local for temporary variables, avoid changing shell options without restoring them, and do not call cd from a hook unless that is the explicitly intended feature. A chpwd hook that changes directories can recursively trigger itself or invalidate the user’s command context.

Zsh’s hook arrays preserve the initial value of $? when each function is invoked, but each function still has its own execution result. An error in one hook function can prevent subsequent functions from running. Make optional behavior fail softly where appropriate, and log a bounded diagnostic instead of allowing a missing cache file or unavailable helper to break prompt setup. Do not turn a status-preservation pattern into || true around every command; that can hide a genuine configuration error.

Keep prompt-time hooks fast and bounded

Anything in precmd runs on the path to every prompt. Repeatedly spawning Git, cloud, package, or network commands can make an interactive shell feel slow. Measure prompt latency in the real repository and on the real filesystem. Cache derived values with explicit invalidation on chpwd, relevant file changes, or a controlled refresh; a cache without an invalidation rule eventually reports stale state.

Avoid polling in periodic when an event-driven update is available. If polling is required, bound the command duration and output, check that a previous instance has completed, and define behavior when the terminal is suspended or disconnected. A background task that outlives its shell can consume resources or write output into a later prompt unexpectedly.

Do not block on DNS or a remote service in preexec merely to record command telemetry. Command hooks can expose sensitive command text, including tokens or paths, so redact or omit data before writing logs. If telemetry is required, keep collection local and bounded, and make the privacy and retention policy explicit. The same care applies to zshaddhistory: filtering one history file does not erase the in-memory line or logs from other tools.

Handle history hooks conservatively

The zshaddhistory return value controls more than whether a line appears in the file. Status 1 leaves the command in the in-memory history even though it is not saved to the history file; status 2 suppresses the file write while retaining it internally. A user who expects a “secret command” to disappear from all history can therefore be surprised. Test the behavior with the active HISTFILE, INC_APPEND_HISTORY, or SHARE_HISTORY settings and any history plugin.

History hooks receive the line before it executes. They should not run or evaluate that line. Parsing shell syntax correctly requires handling quoting, substitutions, aliases, and multi-line constructs; use the provided arguments only for the narrow purpose documented. Avoid printing raw history lines to a debug log, and test what happens when a history plugin changes the history context with fc -p.

The hook system’s special status behavior can be a source of compatibility risk. The manual explicitly notes that only the documented return values are stable for suppressing a history write. Do not depend on undocumented nonzero statuses. Add tests for normal commands, ignored commands, commands with embedded newlines, and commands rejected by the policy; verify both in-memory and on-disk history separately.

Debug ordering and failures

When a prompt behaves inconsistently, list each relevant hook array and its order, then temporarily log a small marker and the captured entry status. Avoid printing the entire prompt or command line if it may contain sensitive text. Confirm whether the base function executes before array entries, whether a missing hook name is silently ignored, and whether an earlier error prevented later functions from running.

Test in a clean interactive shell and in the actual startup path. A hook loaded in .zshrc may not be present in a non-interactive shell, and a plugin manager can source files in a different order than a hand-built test. Test opening a shell, running a successful command, running a failing command, changing directories, receiving a job-exit notification, waiting longer than PERIOD, and exiting the shell.

Keep rollback simple. Remove the one hook being tested, restart a clean shell, and compare behavior. Do not rewrite all hook arrays to remove a slow plugin if other features depend on them. The smallest reversible change makes it easier to identify which hook owns the delay or status change.

Zsh hooks provide well-defined lifecycle points, but all registered functions share the user’s shell context. Choose the right hook, preserve status, respect array ordering, keep prompt work bounded, and test history behavior separately from file persistence.

Related:

Sources:

Comments