Executing vs sourcing a shell script
When you run:
./myscript.sh
you’re not running your script directly. You’re asking your shell to fork() and the child process to call:
execve("./myscript.sh", argv, envp)
This is the standard Unix model. But myscript.sh isn’t an ELF binary — it’s a text file. So how does the kernel know what to do?
If the file starts with the shebang #!, the kernel recognizes this as a “script”. It parses the rest of the line to get the interpreter path (like /bin/bash) and an optional argument (like -e). Then, instead of executing the script file directly, the kernel rewrites the execution context: it replaces the target of the current execve() with the interpreter, and adjusts the argument list to include the script as an argument.
Internally, the kernel opens the interpreter, switches the current exec target to it by updating bprm->file1, preparing the exec state and re-entering the loader – all inside the same execve().
/* fs/binfmt_script.c — inside load_script() after parsing the #! line */
file = open_exec(interp); /* open the interpreter, e.g. /bin/bash */
if (IS_ERR(file))
return PTR_ERR(file);
bprm->file = file; /* switch current exec target to interp */
retval = prepare_binprm(bprm); /* refresh headers/creds/argv buffer */
if (retval < 0)
return retval;
return search_binary_handler(bprm); /* re-enter loader; ELF claims interp */
This is why you need x permission on the script: because you’re asking the kernel to execute it as a program. Even though the interpreter will be /bin/bash, that doesn’t happen unless the kernel first accepts the file as an executable. And that requires x. If you only have read permission, execve() fails before the kernel even checks for a shebang.
Note: the interpreter must be able to read the script, so in practice the script also needs read permission, or the interpreter will fail opening it.
Contrast that with:
bash myscript.sh
Here the shell forks and explicitly calls execve("/bin/bash", ["bash", "myscript.sh"], ...). No need for a shebang and for the script to be executable. You’re running bash, and the script is just a data file that gets opened and read by the interpreter. In both of these cases you’re spawning a new process, so any changes the script makes — environment variables, cd, alias etc., vanish when it exits.
You can also hand your current shell over to the script with:
exec ./myscript.sh
The exec is a shell builtin that does not fork: the shell itself calls execve("./myscript.sh", ...) and is replaced by the interpreter chosen via the shebang. Same permissions rules as ./myscript.sh (needs x, and practically r). Your shell’s PID becomes the script’s interpreter and there’s no return to the old shell when it finishes. Note that exec used only with redirections (without a command) doesn’t call execve(); it just rewires the current shell’s file descriptors.
exec >out.log 2>&1
echo "this goes to out.log"
printf 'pid=%s\n' "$$" # still same shell, continues running
# Restore to the terminal (Linux TTY)
exec >/dev/tty 2>&1
echo "back on screen"
Now, sourcing is a completely different mechanism. When you do:
source myscript.sh
(or . myscript.sh), the shell doesn’t fork, and there’s no execve(). It just opens the file, parses it, and evaluates each command directly in the current shell process. It’s as if you typed them into your terminal, except with proper parsing and file context. Because it’s not executed as a program, sourcing doesn’t require x permission or a shebang. Only read access. And since it runs in the same process, any changes persist:
# myscript.sh
export VAR=42
source myscript.sh
echo $VAR # prints 42
TL;DR: executing a script runs it in a separate process whose image is the interpreter; exec replaces your current shell with that interpreter; sourcing feeds the file into your current shell without creating a new process and has persistent effects.
-
bprmis thestruct linux_binprmthe kernel uses while building the new process image; switchingbprm->fileto the interpreter means “this execve() now executes the interpreter, with the script path already appended in argv.” ↩︎