Skip to content
shellhacks.dev
Shell Scripting

Why a Shell Script Behaves Differently Interactive vs Not

How Bash behaves differently as an interactive or a non-interactive shell: startup files, prompts, aliases, job control, and how to test which one you have.

6 min read
Summarize in AI
Illustration for "Why a Shell Script Behaves Differently Interactive vs Not": a terminal session, an exit status badge, environment variables.

A command that works when typed and fails inside a script often points to two different shell modes. Interactive and non-interactive shells read different startup files and enable different features, and knowing the list explains behaviour that otherwise looks random. This post assumes Bash 5.2 and builds on login versus non-login shells.

What makes a shell interactive

Shell
echo "$-"
[[ $- == *i* ]] && echo "interactive"
bash -c 'echo "$-"; [[ $- == *i* ]] || echo "non-interactive"'
Text
himBHs
interactive
hBc
non-interactive

A shell is interactive when it reads commands from a terminal and no script or -c string was given. The special parameter $- lists active option flags, and the letter i means interactive. Running a script, bash -c, a cron job or an ssh host command are all non-interactive.

What changes

Shell
shopt -s expand_aliases
alias ll='ls -l'
bash -c 'll' 2>&1 | head -1
Text
bash: line 1: ll: command not found

Aliases, which live in the interactive shell only, are not inherited by child processes and are off by default in scripts. Job control (fg, bg, %1) and command history are interactive features. A non-interactive shell exits on a failed set -e command, while an interactive one prints the error and gives you the prompt back. Prompt variables such as PS1 are unset in scripts.

Startup files

An interactive non-login Bash reads ~/.bashrc. A login Bash reads ~/.bash_profile, falling back to ~/.profile. A non-interactive Bash reads none of them, unless the variable BASH_ENV names a file, which it then sources. That is why functions and exports defined in .bashrc are missing in cron and in scripts, as described in scripts that work in the terminal but not in cron.

Guard your .bashrc

Shell
# top of ~/.bashrc
case $- in
  *i*) ;;
  *) return ;;
esac

echo "welcome"

A .bashrc that prints text, runs stty or starts a program breaks protocols that run over a non-interactive shell. scp, sftp and rsync over SSH start one, and unexpected output corrupts the stream, producing errors like "Received message too long". Returning early for non-interactive shells, as most distribution defaults do, prevents it.

Terminal versus interactive

These are different tests. $- asks whether the shell is interactive. [ -t 0 ] or [ -t 1 ] asks whether standard input or output is a terminal. A script run from a terminal is non-interactive but has a TTY, so it may colour output; piped through less it loses the TTY on stdout. Use the second test for colour and prompts, and the first only when sourcing shell setup. Forcing interactive mode with bash -i -c 'cmd' loads .bashrc and aliases, but also prints a "no job control" warning in many setups.

The practical effects, one by one

Interactivity touches more than startup files. Prompt variables such as PS1 are only set in interactive shells, and some scripts test for them to decide whether to run a block. History expansion with the exclamation mark is on in interactive shells and off in scripts, which is why a string like "hello!" works in a script and fails in a terminal with "event not found". Tab completion, readline editing and the checkwinsize option exist only when a person is typing. Even the handling of the interrupt signal differs: an interactive shell ignores it while waiting for input, and a script dies.

These differences are a reason to test scripts in the mode they run in. A script developed by pasting lines into a terminal behaves slightly differently when saved and executed, and a script that works from a terminal may fail when launched by another program with no terminal at all. Run it with bash script.sh < /dev/null before relying on it, and with env -i to approximate a scheduler.

Login shells add a third dimension

Interactive and login are independent properties. A terminal window on a desktop usually starts an interactive non-login shell, and an SSH session starts an interactive login shell, so they read different files. A remote command run as ssh host command starts a non-interactive, non-login shell, though Bash makes a special case and reads ~/.bashrc when it detects that it was started by the remote shell daemon, which is why some people's remote commands see their aliases and others' do not. The behaviour depends on compile-time options in the distribution's Bash, so do not rely on it. Put environment setup in a place you control, such as the script itself or a file it sources explicitly.

Writing dotfiles that behave

A common and sensible pattern is to keep environment variables and path changes in the file read by login shells, keep interactive niceties such as aliases and prompts in the file read by interactive shells, and have the login file source the interactive one so a login terminal gets both. Guard the interactive file so that non-interactive shells exit it immediately. Test by starting each kind of shell and printing a marker: bash -lc 'echo $PATH' for login non-interactive, bash -ic 'echo $PATH' for interactive non-login. If the outputs differ in ways you did not intend, the dotfiles need reorganising.

Scripts that need to know

A script that behaves differently when run by a person should do so deliberately, using the terminal test and not the interactive flag. Ask whether standard output is a terminal before adding colours, progress bars or prompts. Provide flags such as --yes and --no-color so automation can opt out explicitly, and honour the NO_COLOR environment variable convention. Never prompt without a terminal; a script that blocks on a question in a scheduled job hangs until someone notices. If input is needed and none is available, fail with a message that says which flag to pass.

One last example of the gap: environment variables set by a graphical session are visible to terminals started from it and invisible to jobs started by the scheduler or by a service manager. A script that depends on DISPLAY, a session bus address or an agent socket works in a terminal and fails in cron for exactly this reason. If a script needs one of them, pass it in explicitly, as an argument or in the unit file, and document why. Relying on whatever the invoking shell happened to export makes the script depend on its caller, and the dependence only shows when the caller changes.

Where this does not apply

Other shells draw the line differently: zsh reads ~/.zshenv for every invocation, and fish has its own config file. POSIX sh reads the file named by ENV only when interactive. The Bash manual on startup files and the section on interactive shells define all of this precisely.

When debugging, compare the two modes directly. Run env -i bash --noprofile --norc -c 'your command' to get a clean non-interactive shell with no startup files, then add pieces back until the failure appears. That bisect usually points at one exported variable or one alias. The related post bashrc versus bash_profile lists which file to put each setting in so it reaches the right shells.

Also inBash
Taggedscripting
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.