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

POSIX Shell Prefix Assignments: Special Built-ins Preserve State

POSIX shell assignment prefixes can outlive their command when they precede special built-ins; learn the standard rule, Bash mode differences, and safe tests.

MODE=ci utility looks like a one-command environment override: set a value, run one utility, and let the shell restore its previous state. That is the usual model for a regular utility, but it is not the whole POSIX shell contract. When the command name is a POSIX special built-in, assignment words before it affect the current shell execution environment and remain after that built-in completes. The same syntax can therefore be temporary in one command and persistent in another.

This distinction matters most for stateful variables such as PATH, IFS, and locale settings. A prefix assignment that silently remains in effect can change later parsing, command lookup, or child-process environments. A production script should make the target shell and the intended lifetime of each assignment explicit rather than treating every command as an external program.

First distinguish an assignment command from a command prefix

These forms look similar but are parsed differently:

MODE=review
MODE=review some-command

The first simple command has no command name: it changes the shell variable in the current shell environment. The second has a command name, so its behavior depends on the command category. In both forms, the shell recognizes an assignment word syntactically; it does not behave like an ordinary argument such as MODE=review after the command name.

For a regular utility, the shell supplies the prefix assignment in that command’s execution environment, then leaves the shell’s prior value (or unset state) intact. For a POSIX special built-in, the assignment affects the current shell environment and remains after the built-in returns. That persistence rule is part of POSIX.1-2024’s simple-command and special-built-in definitions, not an inference from whether a particular shell happens to implement the utility internally.

The utility category determines the assignment lifetime

POSIX classifies a defined set of shell utilities as special built-ins. The category includes :, ., break, continue, eval, exec, exit, export, readonly, return, set, shift, times, trap, and unset. A shell may implement many other commands as built-ins for speed, but that does not make them POSIX special built-ins. For example, true and read are not in that special list.

Simple command shape Assignment lifetime Example consequence
Assignment words with no command name Current shell MODE=review remains set afterward.
Prefix assignment before a POSIX special built-in Current shell MODE=review set -- item leaves MODE set.
Prefix assignment before a regular built-in or external utility Command execution only MODE=review true does not change the caller’s MODE.
Assignment prefix before a shell function Function invocation rules Do not infer this behavior from the special-built-in rule; test and document the function’s shell contract.

The table is about the variable value in the shell that evaluates the simple command. Export attributes, command lookup, shell functions, redirections, and utility-specific assignments are separate parts of the execution model. In particular, an assignment-only command does not by itself promise that a previously unexported variable will be passed to later external commands. Use export deliberately when child processes need a value.

Verify the rule in a disposable shell

The following fixture contrasts an assignment before a regular utility with one before the special built-in set. The subshell keeps both the variable and positional-parameter changes away from the caller:

(
    unset scope
    scope=temporary true
    regular_after=${scope-unset}

    unset scope
    scope=persistent set -- sample
    special_after=${scope-unset}

    printf 'regular=%s special=%s\n' "$regular_after" "$special_after"
)

In a POSIX-conforming mode, the expected result is:

regular=unset special=persistent

true is a regular utility even when the shell supplies it as a built-in. set is a special built-in, and its -- sample arguments replace the positional parameters only inside the subshell in this example. The distinction being tested is not whether a fork occurred; it is the standard’s classification of the command name and the shell mode that applies that standard.

Run such probes with the exact executable and startup conditions used in production. A test that starts one shell from an interactive profile may inherit aliases, functions, options, or environment that a service, cron job, CI runner, or shebang-launched script will not have. Keep the probe small and run it as a child process so it cannot overwrite the caller’s variables or positional parameters.

read shows how a temporary prefix can still change command behavior

read is a regular shell utility that assigns its named result variables in the current shell, but a prefix assignment to IFS is not retained by the shell. This makes it a useful example of two different kinds of state in one command: read’s documented output variables remain available, while the temporary field-separator setting is restored.

(
    IFS=';'
    IFS=: read -r left right <<'EOF'
alpha:beta
EOF
    printf 'left=%s right=%s IFS=%s\n' "$left" "$right" "$IFS"
)

The expected output is left=alpha right=beta IFS=;. The IFS=: prefix controls how this invocation of read splits its input line. The read utility stores left and right as its explicit result, but the shell restores the prior IFS value after the regular built-in finishes. -r prevents backslashes from being treated as escape characters by read; it does not change the variable-assignment scope rule.

This is different from assigning IFS before set, export, or another POSIX special built-in. A prefix before a special built-in can become persistent state. If a script temporarily needs a different shell variable around multiple operations, a subshell group is a clear boundary:

(
    IFS=:
    # Operations that intentionally use colon as the shell's field separator.
    # The parent shell's IFS is unchanged when this group completes.
)

Keep the group limited to the work that needs the changed value. A subshell also has its own working-directory changes, traps, and other execution state, so it is not a transparent wrapper for commands that are expected to update the caller.

Bash’s default mode is not the POSIX-mode contract

Bash documents this difference directly. In POSIX mode, assignment statements before POSIX special built-ins remain in the shell environment after the built-in completes. Outside POSIX mode, Bash handles these built-ins like its other Bash built-ins for this rule, so a prefix assignment can be temporary instead. Bash enters POSIX mode when invoked as sh; it can also be enabled with --posix or set -o posix.

That mode distinction is easy to miss in a script tested only at an interactive Bash prompt. The same file can be run by bash, bash --posix, or an implementation selected by #!/bin/sh, and each invocation establishes its own compatibility contract. Test the actual shebang path and explicit shell modes rather than assuming that a successful bash script.sh proves POSIX behavior.

Declaration built-ins add another trap. Bash treats assignment-like operands to commands such as export and readonly using declaration-command rules, including in cases where the ordinary POSIX prefix-assignment behavior is not active. Consequently, a test such as MODE=value export MODE can mix two mechanisms: a prefix assignment and export’s own variable/attribute operation. To isolate the special-built-in prefix rule, use a built-in such as set whose operands do not themselves assign or export the tested variable, as in the fixture above.

POSIX special-built-in status also affects more than variable persistence: it participates in command lookup and error handling. Bash documents that, in POSIX mode, special built-ins are found before shell functions and that certain errors from them can terminate a non-interactive shell. Keep those effects in mind when changing a command name or wrapping a special built-in; a wrapper is not always a behavior-neutral substitution.

Choose the scope deliberately in operational scripts

If the purpose is to configure one external utility, prefer an environment assignment that clearly belongs to that child process. The POSIX env utility is one option:

printf '%s\n' z a | env LC_ALL=C sort

Here env sets LC_ALL for the utility it launches; the invoking shell’s own LC_ALL is not changed. The assignment still does not make arbitrary input safe, and the selected utility’s locale-sensitive behavior should be checked against the platform where the script runs.

If a prefix before a special built-in is intentional, place it in a small subshell when later commands must not inherit it. If it is not intentional, prefer a standalone assignment with an explicit scope, or save and restore state with error paths included. Be particularly cautious with PATH and IFS: an unexpected persistent PATH can redirect later utility lookup, while an unexpected IFS can alter later field splitting or read operations. Do not assume that quoting a variable prevents such shell-state changes; quoting addresses word expansion, not assignment lifetime.

Review each command in this order: identify assignment words before the command name; resolve the command to a POSIX special built-in, regular built-in, utility, or function under the actual shell mode; decide whether the variable should exist only for that operation or remain in the shell; and test the post-command value in a disposable process. That makes an easily overlooked POSIX rule visible during code review rather than after a later command receives a surprising environment.

Related:

Sources:

Comments