SH File Documentation
Summary
A Bash Shell Script is a plain-text file of Unix commands that a shell (usually Bash, sometimes the POSIX /bin/sh) runs top to bottom. It has no binary signature; the first line is normally a shebang like #!/bin/bash. Any text editor opens a .sh file, and its MIME type is application/x-sh. To run one on Linux or macOS, mark it executable with chmod +x and call ./script.sh.
Technical details
| Feature | Value |
|---|---|
| Full name | Shell script (Bourne / Bash) |
| File extension | .sh |
| MIME type | application/x-sh |
| Format type | Plain-text executable script (no binary signature) |
| Developer | Bourne shell — Stephen Bourne, Bell Labs; Bash — GNU Project |
| Introduced | 1979 (Bourne shell, Unix V7); Bash 1989 |
| Standard | POSIX Shell Command Language (IEEE Std 1003.1) |
| Open standard | Yes |
| Text encoding | ASCII or UTF-8 |
| First line | Shebang #!/bin/sh or #!/bin/bash (conventional, not required) |
| Comment syntax | # comment to end of line |
| Variable assignment | No spaces around =, e.g. VAR=value |
| Conditionals | if, then, elif, else, fi, case |
| Loops | for, while, until |
| Function definition | name() { … } |
| Argument passing | $1, $2, … ${10}; count in $# |
| Exit status | Exit code of the last command in $? (0 = success) |
| Script inclusion | source file.sh or . file.sh |
| Error handling | set -e, trap |
| Debugging | set -x for command tracing |
| Execution | Needs the execute bit (chmod +x) to run as ./script.sh |
| Runs on | Linux, macOS, other Unix-like systems; Windows via WSL, Git Bash, or Cygwin |
| Related extensions | .bash, .zsh, .ksh, .command, .run, .bat, .ps1 |
| Specification | pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html |
What is an SH file?
An SH file is a shell script: a plain-text file holding a sequence of commands for a Unix shell to execute in order. The shell is the command-line interpreter of Unix-like systems, so a .sh file is a small program written in the shell's own language. The original Bourne shell (sh) was written by Stephen Bourne at Bell Labs and shipped with Unix Version 7 in 1979; its command language was later standardized by POSIX (IEEE Std 1003.1). The most widely used interpreter today is GNU Bash (Bourne-Again Shell, first released in 1989), while Zsh has been the default login shell on macOS since Catalina (2019).
The file itself carries no binary structure. It is a stream of bytes in ASCII or, more commonly now, UTF-8, with lines separated by a single line-feed byte (0x0A). There is no header, no compilation step, and no magic number that a tool can read to say “this is a shell script”. Identification is behavioural: the file is a script because a shell can run it, and by convention its first line is a shebang that names the interpreter. The MIME type registered for it is application/x-sh. Because it is ordinary text, you can read every instruction it will run before you run it, which matters for the safety section below.
The shebang and execve()
The mechanism that makes a text file behave like an executable is the shebang. Its first two bytes are 0x23 0x21, the ASCII characters # and !, and they must sit at offset 0 with nothing before them, not even a byte-order mark. Everything after #! on that first line is the interpreter specification: an absolute path to a program, optionally followed by a single argument. Typical forms are #!/bin/sh, #!/bin/bash, and #!/usr/bin/env bash.
The kernel, not the shell, is what acts on the shebang. When you run a file, the C library calls the execve() system call. The kernel reads the first bytes of the target; if they are #! it treats the file as an interpreter script rather than a native binary. It then re-invokes execve() on the interpreter named after #!, passing the script's own path as an argument. So running ./deploy.sh whose first line is #!/bin/bash effectively becomes an execution of /bin/bash ./deploy.sh. The interpreter opens the file, skips the shebang line (it starts with #, a comment to the shell), and runs the rest.
Two details of this mechanism trip people up. First, on Linux the shebang line is truncated to a fixed buffer, historically 127 characters (BINPRM_BUF_SIZE); a very long interpreter path plus argument is silently cut off, which produces confusing “bad interpreter” errors. Second, only one argument is passed after the interpreter path, and the whole remainder is treated as a single argument on most kernels, so #!/usr/bin/env bash -e does not reliably pass -e to Bash. The #!/usr/bin/env bash idiom exists because the kernel needs an absolute path: env lives at a known location (/usr/bin/env) and then searches PATH for bash, so the script runs even where Bash is installed somewhere other than /bin, such as /usr/local/bin on many BSD and Homebrew systems.
The shebang is a convention, not a requirement. If a script has no shebang and you execute it directly, the calling shell runs the file itself in a child process (POSIX specifies the file is read by sh-compatible processing). If instead you name the interpreter on the command line, as in bash script.sh, the shebang is ignored entirely: the interpreter you named opens the file and runs it, and any #! line is just a comment.
Execute permission and how a script is invoked
Whether a .sh file needs the execute permission bit depends entirely on how you invoke it, and this is the single most common source of confusion. Running a file directly, as ./script.sh, asks the kernel to execve() it; the kernel refuses unless the execute bit (x) is set for the relevant user class, and you get Permission denied. The leading ./ is also required: without it the shell searches PATH for a command named script.sh and, not finding one, reports command not found. Set the bit once with chmod +x script.sh.
Handing the file to an interpreter is different. sh script.sh or bash script.sh starts the interpreter as the program and passes the script as a plain file argument to read; the kernel only checks the read bit, never the execute bit, so no chmod is needed. The interpreter also ignores the shebang in this case, because the interpreter choice was already made on the command line. The table below summarises the four common invocation methods.
| Invocation | Needs +x? | Interpreter used | Shebang honoured? |
|---|---|---|---|
./script.sh | Yes | The one named in the shebang (via kernel) | Yes |
bash script.sh | No | Bash, explicitly | No |
sh script.sh | No | Whatever /bin/sh is (often dash) | No |
source script.sh / . script.sh | No | Current shell (no child process) | No |
The last row is worth noting because it changes behaviour, not just permissions: source (or its POSIX form .) runs the script in the current shell rather than a child process, so variable assignments and cd commands persist after it finishes. This is how shells load configuration files such as ~/.bashrc. A script that calls exit while sourced will close your interactive shell, which is a common surprise.
POSIX sh vs Bash extensions
sh refers to the POSIX shell: the Shell Command Language defined in IEEE Std 1003.1. On most modern Linux systems /bin/sh is not Bash at all but a symlink to a smaller, faster, strict shell such as dash (the Debian Almquist shell). bash is a superset that adds constructs POSIX sh does not define. The features most likely to break portability are the [[ ... ]] conditional command, indexed and associative arrays, the ((...)) arithmetic command, process substitution with <(...), the local keyword for function-scoped variables, brace expansion, and the C-style for ((i=0; i<n; i++)) loop. A script that declares #!/bin/bash may use all of these; a script that declares #!/bin/sh must avoid them or it will fail on a POSIX-only shell.
The failure is easy to demonstrate. This script uses two Bash-only constructs, an array and [[ ]], plus process substitution:
#!/bin/bash
# Bash-only: array, [[ ]], and process substitution
names=(alice bob carol)
for n in "${names[@]}"; do
if [[ "$n" == a* ]]; then
echo "$n starts with a"
fi
done
# read a command's output without a temp file
while read -r line; do echo "seen: $line"; done < <(printf '%s\n' one two)
Run as bash script.sh it works. Run as sh script.sh under dash it aborts at names=(...), which is a syntax error in POSIX, and <(...) is not recognised either. The portable rewrite drops the array, replaces [[ ]] with a case statement, and avoids process substitution:
#!/bin/sh
# POSIX-portable version
for n in alice bob carol; do
case "$n" in
a*) echo "$n starts with a" ;;
esac
done
printf '%s\n' one two | while read -r line; do
echo "seen: $line"
done
The rule is simple: if a script must run anywhere, target #!/bin/sh and stay inside POSIX; if you want the convenience of arrays and [[ ]], target #!/bin/bash and accept the dependency on Bash. The dedicated BASH and ZSH extensions cover the shell-specific dialects in more depth.
Why CRLF breaks a script
Unix shells expect lines to end with a single line-feed byte (LF, 0x0A). Windows editors often save with a carriage-return plus line-feed pair (CRLF, 0x0D 0x0A). When such a file runs on Linux or macOS, the shell does not strip the trailing 0x0D; it becomes part of the token on each line, with two visible consequences.
The worst hits the shebang. The kernel reads the first line as the interpreter path and, if the line ends in CRLF, the path becomes /bin/bash\r. No such file exists, so execve() fails with a message like bad interpreter: No such file or directory, sometimes rendered as /bin/bash^M. The path looks correct on screen because the carriage return is invisible. Inside the body, a line such as name="value" assigns the string value\r including the stray carriage return, so later comparisons fail and output can appear to overwrite itself when the terminal processes the CR. The fix is to convert line endings with dos2unix script.sh, or in an editor set the file to LF. This is why shell scripts committed to Git are often protected with a .gitattributes rule such as *.sh text eol=lf. Windows-native scripting formats such as BAT and PS1 expect CRLF and do not have this problem, which is part of why porting between them is a rewrite rather than a line-ending swap.
Here-documents, exit status, and strict mode
Beyond the shebang, a few constructs appear in almost every real script. A here-document feeds a block of literal text to a command's standard input without a separate file. The << operator reads until a delimiter word appears alone on a line:
cat <<'EOF' > config.ini
host=localhost
port=8080
EOF
Quoting the delimiter as 'EOF' disables variable expansion inside the body; an unquoted EOF would expand $variables. The variant <<- strips leading tab characters so the block can be indented.
Every command a script runs sets an exit status: an integer from 0 to 255 where 0 means success and any non-zero value signals an error. The shell exposes the last status in $?, and control flow such as if and && tests it. By default a shell script keeps going after a command fails, which hides errors. The widely used guard against that is the strict-mode line placed just after the shebang:
#!/bin/bash
set -euo pipefail
set -e makes the script exit on the first command that returns non-zero; set -u treats the use of an unset variable as an error rather than an empty string; and set -o pipefail makes a pipeline fail if any command in it fails, not just the last. The combination turns silent failures into loud, early exits, which is why it opens most production scripts. It is a Bash and modern-shell feature; plain POSIX sh supports -e and -u but not pipefail.
Is an SH file safe to run?
Reading or editing a .sh file is completely safe, because it is only text and nothing executes while you look at it. Running one is where the risk lives. A shell script is a list of commands that execute with the full privileges of the user who runs it: there is no sandbox, no permission prompt, and no capability boundary. The moment you run it, it can delete your files, install software, change system configuration, or fetch and execute more code, all with your identity. If you run it under sudo, it does all of that as root.
The specific pattern to distrust is curl https://example.com/install.sh | bash. Here the remote script is piped straight into a shell and executed without ever touching disk, so you never see what runs, and the server can even serve different content to the pipe than it shows in a browser. The same danger applies to obfuscated one-liners, long base64 blobs decoded and piped to a shell, and self-extracting installers (built with shar or Makeself) that append a compressed payload after the script text. None of these are automatically malicious, but each removes your chance to inspect the code.
The defence follows directly from the format: a .sh file is plain text, so download it first, open it in an editor, and read it before running. Look for unexpected sudo, writes outside the project directory, network fetches, and any decode-and-execute step. Prefer curl -o install.sh URL then reading the file over piping to a shell. With scripts, the source you are trusting is the code itself, and that code is right there in front of you.
Frequently asked questions
Why does a shebang allow only one argument after the interpreter?
The kernel's handling of #! in execve() was implemented to split the line into at most the interpreter path and a single argument string, and behaviour with multiple arguments has never been portable across Unix kernels. So #!/usr/bin/env bash -e may pass bash -e as one combined token rather than a flag. To set options reliably, use set -e on the next line instead of putting flags in the shebang.
Why does a Bash script fail under /bin/sh even though sh exists?
On most Linux systems /bin/sh is a symlink to dash, a strict POSIX shell, not to Bash. Running a script with sh script.sh therefore uses dash regardless of a #!/bin/bash shebang, because naming sh on the command line overrides the shebang. Bash-only syntax such as arrays or [[ ]] is a syntax error in dash, so the script aborts.
Why does my script report “bad interpreter” when the path is correct?
The file almost certainly has Windows CRLF line endings. The invisible carriage return at the end of the first line makes the interpreter path /bin/bash\r, which does not exist, so execve() fails even though the visible text reads /bin/bash. Convert the file with dos2unix script.sh or set your editor to save with LF endings.
References
- The Open Group — Shell Command Language (POSIX)
- GNU — Bash Reference Manual
- Microsoft — Windows Subsystem for Linux
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.