Skip to content

What Is Shell Script?


Open a text file, type a few Bash commands into it, and save it as backup.sh. Is it a script yet? Not quite — a .sh extension is just a naming convention that tells a human (or an editor) what’s inside. What actually makes it a runnable script, and what the operating system does the moment you try to run it, is the part most tutorials skip past. This chapter covers that mechanism in full: the shebang line, execute permissions, the difference between running a script and sourcing it, and your first look at exit status — the signal every script sends back about whether it succeeded.

What Actually Makes A File A Script?

Three things combine to make a file “a script” in the sense that your shell and the kernel understand it:

  1. The file contains a sequence of commands the interpreter can read line by line.
  2. The first line tells the kernel which interpreter to hand the file to — this is the shebang, covered next.
  3. The file has execute permission, if you intend to run it directly (as opposed to feeding it to an interpreter explicitly).

None of these depend on the filename. backup.sh, backup, and backup.txt are all equally valid scripts to the kernel — the .sh suffix is purely a courtesy to anyone reading a file listing. Plenty of production scripts (much of what’s in /usr/bin on your system right now) have no extension at all.

The Shebang Line

The shebang is the #! sequence at the very start of a script, followed by the path to an interpreter:

#!/usr/bin/env bash

This has to be the first two bytes of the file — no blank line, no comment, nothing before it. If it isn’t, the kernel won’t recognize it as a shebang at all, and it’ll just be treated as a comment (since # starts a comment in Bash).

Here’s what happens mechanically: when you try to execute a file directly (./script.sh), the kernel’s execve() system call inspects the first two bytes. If they’re #!, the kernel reads the rest of that line as an interpreter path, plus — optionally — a single argument to pass to it. It then re-invokes that interpreter, handing it your script’s path as an argument, roughly as if you’d typed:

/usr/bin/env bash /path/to/script.sh

That’s the entire trick. The shebang doesn’t make Bash “magically” run your file — it tells the kernel which program should run it, and the kernel does the substitution for you.

#!/bin/bash vs. #!/usr/bin/env bash

You’ll see both in the wild, and the difference matters:

  • #!/bin/bash assumes Bash lives at exactly /bin/bash. This is true on the vast majority of Linux systems, but not guaranteed everywhere — some systems install Bash elsewhere, or don’t have it at /bin/bash at all.
  • #!/usr/bin/env bash asks env to search your PATH for whichever bash comes first, and run that. This is more portable across systems where Bash’s location varies.

Both are common; which one to prefer is covered under Best Practices below.

Note

The shebang can only take one argument after the interpreter path. #!/usr/bin/env bash -x won’t work the way you might expect — some systems will pass bash -x as a single (invalid) argument to env rather than splitting it into two. If you need extra flags, set them inside the script with set -x rather than on the shebang line.

Execute Permissions

Having a shebang isn’t enough on its own — the file also needs the execute bit set before you can run it directly. Check a file’s permissions with:

ls -l script.sh
-rw-r--r-- 1 you you 142 Aug 22 09:00 script.sh

That -rw-r--r-- has no x anywhere — this file isn’t executable yet. Add execute permission with:

chmod +x script.sh
-rwxr-xr-x 1 you you 142 Aug 22 09:00 script.sh

Now the x flags are present for owner, group, and others. Without this step, trying to run ./script.sh fails with Permission denied — regardless of how correct the shebang or the script’s contents are. This trips up newcomers constantly: the error message says “permission,” not “syntax” or “not found,” because permission really is the only thing missing.

Running A Script: Direct Execution vs. bash script.sh

There are two distinct ways to run a script, and they behave differently.

Direct execution (./script.sh) requires both the execute bit and a working shebang. The kernel reads the shebang and hands the file to whatever interpreter it names. If the shebang is missing, broken, or points at something that doesn’t exist, direct execution fails outright.

Explicit interpreter invocation (bash script.sh) sidesteps all of that. You’re telling Bash directly, “read this file and run it as commands.” The execute bit isn’t required — Bash just opens the file and reads it, the same way it would read any other file you pointed it at. The shebang line, if present, is ignored entirely; it’s just treated as a comment, since you’ve already chosen the interpreter yourself by typing bash.

This distinction matters in practice. If you ever see a script fail with Permission denied when run as ./script.sh but work fine as bash script.sh, you now know exactly why — and that chmod +x is the actual fix, not something wrong with the script’s contents.

Both approaches run the script in a new child process. Any variables the script sets, any cd commands it runs, any environment changes it makes — all of that disappears the moment the script finishes, and your current shell is completely unaffected. This is the key difference from sourcing, covered next.

Sourcing A Script

Sourcing runs a script’s commands inside your current shell, rather than spinning up a new process for it. You do this with the source builtin, or its shorthand, a single dot:

source script.sh
# or, identically:
. script.sh

Because sourcing doesn’t create a new process, anything the script does to the shell environment — setting variables, defining functions, changing directories — persists in your current shell after the script finishes. This is exactly why sourcing exists: it’s how you load shared functions, environment variables, or configuration into your current session rather than a disposable one.

A few consequences follow directly from this:

  • Sourcing doesn’t require execute permission. You’re not asking the kernel to run the file as a program — you’re asking Bash to read its contents into the shell you’re already in, the same way bash script.sh does.
  • The shebang line is irrelevant when sourcing, for the same reason. It’s just a comment; you’ve already chosen the interpreter (your current shell) by using source.
  • Anything the sourced script does — cd somewhere, unset SOME_VAR, setting PATH — sticks around in your shell afterward. A script written carelessly, expecting to run in an isolated process, can leave your interactive shell in a different state than you started with.

That last point has a sharper edge to it, covered under Shell-Safety Considerations below.

Exit Status: A First Look

Every command you run — including every script — finishes by reporting an exit status: a number from 0 to 255 that indicates success or failure. By convention:

  • 0 means success.
  • Any nonzero value means failure (the specific number is often used to indicate which kind of failure, by convention of whatever program set it).

Bash stores the exit status of the most recently completed command in the special variable $?. Check it immediately after running something:

ls /tmp
echo $?
0
ls /this/does/not/exist
echo $?
2

A script’s own exit status works the same way from the outside — when your script finishes, whoever ran it (another script, a && chain, a CI pipeline) can check $? to see whether it succeeded. By default, a script’s exit status is the exit status of the last command it ran. You can also set it explicitly with the exit builtin:

exit 0    # success
exit 1    # generic failure

This is only a first look — you’ll cover exit status in much more depth (including &&, ||, and chaining logic around it) in Section C. For now, the important habit to build is checking $? (or better, structuring commands so you don’t have to) any time you’re unsure whether something actually succeeded.

Best Practices

  • Prefer #!/usr/bin/env bash for portability, unless you control the exact environment the script will run in (a specific container image or server where you know Bash’s path) and have a concrete reason to hardcode #!/bin/bash. env-based shebangs are the safer default.
  • Always chmod +x scripts meant to be run directly. If a script is meant only to be sourced (a shared function library, for example), you can skip this — it isn’t needed for sourcing.
  • Keep the shebang as the literal first line, with nothing before it — not even a blank line or a license header comment.
  • Use a .sh extension for clarity, even though nothing about Bash requires it. It’s a convention that helps humans and editors, not a technical necessity — don’t confuse “convention” with “requirement” when reading other people’s scripts that lack one.
  • Don’t rely on execute permission alone as a sign a script is safe to run. Permission bits control whether something can run, not whether it’s trustworthy — that distinction becomes especially important with sourcing, next.

Shell-Safety Considerations

The direct-execution-vs-sourcing distinction isn’t just academic — it has a real safety implication around exit.

The problem: exit terminates the process it’s running in. When a script runs via ./script.sh or bash script.sh, that process is a disposable child process, so exit just ends the script — your shell is untouched. But when a script is sourced, there is no separate child process. The script’s commands run directly in your current shell. If a sourced script calls exit, it terminates your current shell, not just the script — closing your terminal session (or, in a terminal multiplexer, that pane/tab) outright.

Here’s the naive version of a script that looks completely reasonable on its own, but is dangerous to source:

#!/usr/bin/env bash

echo "Checking for required config..."
if [[ ! -f "/tmp/shell-course-demo/config.txt" ]]; then
    echo "Config not found — aborting."
    exit 1
fi
echo "Config loaded."

Run directly (./check-config.sh), this behaves exactly as intended: it prints a message and exits with status 1 if the config is missing, with zero effect on your shell. But if someone sources it — perhaps because they wanted the echo messages to run “in place,” or copied a source command from documentation without checking what the script does — and the config file happens to be missing, exit 1 will close their entire shell session the moment it runs.

The fix: scripts that might reasonably be sourced shouldn’t call exit for ordinary failure handling. Instead, let the script’s last command’s own exit status speak for it, or restructure the failure path so nothing explicitly terminates the process:

#!/usr/bin/env bash

echo "Checking for required config..."
if [[ ! -f "/tmp/shell-course-demo/config.txt" ]]; then
    echo "Config not found — aborting."
    false
else
    echo "Config loaded."
fi

Here, false is a builtin that does nothing except return a nonzero exit status — no process or shell termination involved. Run directly, $? after the script is still 1 when the config is missing, giving identical behavior to the exit 1 version for anyone executing it normally. Sourced, it simply leaves $? set to 1 in the current shell without closing anything.

Warning

Never source a script you haven’t read, especially from an untrusted or unfamiliar source. Sourcing gives it full, unsandboxed access to modify your current shell — variables, functions, your working directory, even your shell options — with none of the isolation that running it as a separate process would provide.

Hands-On: Building And Running Your First Script

This walkthrough covers the whole chapter end to end: writing a shebang, setting execute permissions, running a script both directly and via bash, sourcing it, and observing the exit-while-sourced problem safely.

1. Set up a working directory.

mkdir -p ~/shell-course/ch1
cd ~/shell-course/ch1

2. Create your first script. Save the following as greet.sh:

#!/usr/bin/env bash

echo "Hello from a script."
echo "The current working directory is: $(pwd)"
exit 0

3. Try running it before setting execute permission.

./greet.sh
bash: ./greet.sh: Permission denied

This is the permission problem described earlier — the shebang and contents are fine, the execute bit just isn’t set yet.

4. Set execute permission and run it directly.

chmod +x greet.sh
./greet.sh
echo "Exit status: $?"
Hello from a script.
The current working directory is: /home/you/shell-course/ch1
Exit status: 0

5. Run it via explicit interpreter invocation instead. First, remove execute permission to prove it isn’t needed this way:

chmod -x greet.sh
bash greet.sh
echo "Exit status: $?"
Hello from a script.
The current working directory is: /home/you/shell-course/ch1
Exit status: 0

Same output, despite the file no longer being executable — confirming that bash script.sh doesn’t need the execute bit at all.

6. Observe sourcing’s effect on the current shell. Restore execute permission, then set a variable inside a script and source it:

chmod +x greet.sh
cat > env-demo.sh << 'EOF'
#!/usr/bin/env bash

DEMO_MESSAGE="set by env-demo.sh"
EOF

source env-demo.sh
echo "$DEMO_MESSAGE"
set by env-demo.sh

That variable is now sitting in your current shell, even though env-demo.sh finished running some time ago — because sourcing never left your shell in the first place.

7. See the exit-while-sourced problem safely. Open a new terminal tab or window before trying this step — if it goes as described, it will close that terminal.

mkdir -p /tmp/shell-course-demo
cat > check-config.sh << 'EOF'
#!/usr/bin/env bash

echo "Checking for required config..."
if [[ ! -f "/tmp/shell-course-demo/config.txt" ]]; then
    echo "Config not found — aborting."
    exit 1
fi
echo "Config loaded."
EOF

source check-config.sh

Since /tmp/shell-course-demo/config.txt doesn’t exist, the exit 1 inside the sourced script terminates that terminal session. Confirm the fix by editing the same script to replace exit 1 with false, then source it again in a fresh tab — this time, the terminal survives, and echo $? afterward reports 1.

Don’t feel overwhelmed already, it’s just one of the behavior that you should be aware of which comes with practice.

8. Clean up.

rm -rf ~/shell-course/ch1
rm -rf /tmp/shell-course-demo

You’ve now seen every mechanism this chapter covers acting on the same handful of files: the shebang choosing an interpreter, execute permission gating direct execution, the process-isolation difference between running and sourcing, and the very real consequences of calling exit in the wrong context. The next chapter builds directly on this foundation with variables — how Bash stores, quotes, and distinguishes shell variables from environment variables.

Last updated on