How is the return status of a variable assignment determined?

It is documented (for POSIX) in Section 2.9.1 Simple Commands of The Open Group Base Specifications.  There's a wall of text there; I direct your attention to the last paragraph:

If there is a command name, execution shall continue as described in Command Search and Execution.  If there is no command name, but the command contained a command substitution, the command shall complete with the exit status of the last command substitution performed.  Otherwise, the command shall complete with a zero exit status.

So, for example,

   Command                                         Exit Status
$ FOO=BAR                                   0 (but see also the note from icarus, below)
$ FOO=$(bar)                                Exit status from "bar"
$ FOO=$(bar)$(quux)                         Exit status from "quux"
$ FOO=$(bar) baz                            Exit status from "baz"
$ foo $(bar)                                Exit status from "foo"

This is how bash works, too.  But see also the “not so simple” section at the end.

phk, in his question Assignments are like commands with an exit status except when there’s command substitution?, suggests

… it appears as if an assignment itself counts as a command … with a zero exit value, but which applies before the right side of the assignment (e.g., a command substitution call…)

That’s not a terrible way of looking at it.  A crude scheme for determining the return status of a simple command (one not containing ;, &, |, && or ||) is:

  • Scan the line from left to right until you reach the end or a command word (typically a program name).

  • If you see a variable assignment, the return status for the line just might be 0.

  • If you see a command substitution — i.e., $(…) — take the exit status from that command.

  • If you reach an actual command (not in a command substitution), take the exit status from that command.

  • The return status for the line is the last number you encountered.
    Command substitutions as arguments to the command, e.g., foo $(bar), don’t count; you get the exit status from foo.  To paraphrase phk’s notation, the behavior here is

      temporary_variable  = EXECUTE( "bar" )
      overall_exit_status = EXECUTE( "foo", temporary_variable )
    

But this is a slight oversimplification.  The overall return status from

A=$(cmd1)  B=$(cmd2)  C=$(cmd3)  D=$(cmd4)  E=mc2
is the exit status from cmd4.  The E= assignment that occurs after the D= assignment does not set the overall exit status to 0.

icarus, in his answer to phk’s question, raises an important point: variables can be set as readonly.  The third-to-last paragraph in Section 2.9.1 of the POSIX standard says,

If any of the variable assignments attempt to assign a value to a variable for which the readonly attribute is set in the current shell environment (regardless of whether the assignment is made in that environment), a variable assignment error shall occur.  See Consequences of Shell Errors for the consequences of these errors.

so if you say

readonly A
C=Garfield A=Felix T=Tigger

the return status is 1.  It doesn’t matter if the strings Garfield, Felix, and/or Tigger are replaced with command substitution(s) — but see notes below.

Section 2.8.1 Consequences of Shell Errors has another bunch of text, and a table, and ends with

In all of the cases shown in the table where an interactive shell is required not to exit, the shell shall not perform any further processing of the command in which the error occurred.

Some of the details make sense; some don’t:

  • The A= assignment sometimes aborts the command line, as that last sentence seems to specify.  In the above example, C is set to Garfield, but T is not set (and, of course, neither is A).
  • Similarly, C=$(cmd1) A=$(cmd2) T=$(cmd3) executes cmd1 but not cmd3.
    But, in my versions of bash (which include 4.1.X and 4.3.X), it does execute cmd2.  (Incidentally, this further impeaches phk’s interpretation that the exit value of the assignment applies before the right side of the assignment.)

But here’s a surprise:

In my versions of bash,

readonly A
C=something A=something T=something cmd0

does execute cmd0.  In particular,

C=$(cmd1)   A=$(cmd2)   T=$(cmd3)   cmd0
executes cmd1 and cmd3, but not cmd2.  (Note that this is the opposite of its behavior when there is no command.)  And it sets T (as well as C) in the environment of cmd0.  I wonder whether this is a bug in bash.


Not so simple:

The first paragraph of this answer refers to “simple commands”.  The specification says,

A “simple command” is a sequence of optional variable assignments and redirections, in any sequence, optionally followed by words and redirections, terminated by a control operator.

These are statements like the ones in my first example block:

$ FOO=BAR
$ FOO=$(bar)
$ FOO=$(bar) baz
$ foo $(bar)

the first three of which include variable assignments, and the last three of which include command substitutions.

But some variable assignments aren’t quite so simple.  bash(1) says,

Assignment statements may also appear as arguments to the alias, declare, typeset, export, readonly, and local builtin commands (declaration commands).

For export, the POSIX specification says,

EXIT STATUS

    0
      All name operands were successfully exported.
    >0
      At least one name could not be exported, or the -p option was specified and an error occurred.

And POSIX doesn’t support local, but bash(1) says,

It is an error to use local when not within a function.  The return status is 0 unless local is used outside a function, an invalid name is supplied, or name is a readonly variable.

Reading between the lines, we can see that declaration commands like

export FOO=$(bar)

and

local FOO=$(bar)

are more like

foo $(bar)

insofar as they ignore the exit status from bar and give you an exit status based on the main command (export, local, or foo).  So we have weirdness like

   Command                                           Exit Status
$ FOO=$(bar)                                    Exit status from "bar"
                                                  (unless FOO is readonly)
$ export FOO=$(bar)                             0 (unless FOO is readonly,
                                                  or other error from “export”)
$ local FOO=$(bar)                              0 (unless FOO is readonly,
                                                  statement is not in a function,
                                                  or other error from “local”)

which we can demonstrate with

$ export FRIDAY=$(date -d tomorrow)
$ echo "FRIDAY   = $FRIDAY, status = $?"
FRIDAY   = Fri, May 04, 2018  8:58:30 PM, status = 0
$ export SATURDAY=$(date -d "day after tomorrow")
date: invalid date ‘day after tomorrow’
$ echo "SATURDAY = $SATURDAY, status = $?"
SATURDAY = , status = 0

and

myfunc() {
    local x=$(echo "Foo"; true);  echo "x = $x -> $?"
    local y=$(echo "Bar"; false); echo "y = $y -> $?"
    echo -n "BUT! "
    local z; z=$(echo "Baz"; false); echo "z = $z -> $?"
}

$ myfunc
x = Foo -> 0
y = Bar -> 0
BUT! z = Baz -> 1

Luckily ShellCheck catches the error and raises SC2155, which advises that

export foo="$(mycmd)"

should be changed to

foo=$(mycmd)
export foo

and

local foo="$(mycmd)"

should be changed to

local foo
foo=$(mycmd)

Credit and Reference

I got the idea of concatenating command substitutions — $(bar)$(quux) — from Gilles’s answer to How can I get bash to exit on backtick failure in a similar way to pipefail?, which contains a lot of information relevant to this question.


It is documented in Bash (LESS=+/'^SIMPLE COMMAND EXPANSION' bash):

If there is a command name left after expansion ... . Otherwise, the command exits. ... If there were no command substitutions, the command exits with a status of zero.

In other words (my words):

If there is no command name left after expansion, and no command substitutions were executed, the command line exits with a status of zero.