Basics

What is a subshell — and why your loop counter is back to 0

Illustration: under a glass dome, a tiny copy of a kitchen where a tally counter clicks, while the real counter outside stays at zero

Here's a four-line script. It counts the lines in a file:

count=0
cat todo.txt | while read line; do
  count=$((count + 1))
done
echo "$count"

The file has five lines. The loop honestly walks through all five. And echo prints 0.

No error. No typo. The loop just ran in a different room. That room is called a subshell. Let's see where it shows up — and why you run into it every day, even without knowing the word.

What a subshell is

A subshell is a copy of your shell, started as a separate process. It gets everything the parent had: variables, the current folder, functions.

But the link goes one way. The copy sees the parent. The parent doesn't see the copy.

Anything the subshell changes disappears with it. A new variable, a cd into another folder, an export — it all stays inside.

Think of a photocopy of a contract. You can scribble all over the copy. The original doesn't change.

Where it appears on its own

You almost never write "make me a subshell." Bash does it for you in four places:

  1. Parentheses ( ... ). Everything inside runs in a copy.
  2. Command substitution $(...). For example, today=$(date +%F). The command runs in a copy; only its output comes back.
  3. A pipe |. In bash, every part of a pipe is a separate process. The last one included.
  4. Background &. A background command is a copy too.

Number three killed our counter. The while came after |, so it lived in a copy. The copy counted to five and quietly vanished.

You can check where you are with the $BASH_SUBSHELL variable:

echo $BASH_SUBSHELL        # 0 — you're in the main shell
(echo $BASH_SUBSHELL)      # 1 — inside the parentheses

Here's a sneaky detail: in zsh (the default shell on macOS) the same script prints 5. Zsh runs the last part of a pipe right in the current shell. So the script "works on my machine" and breaks on a server running bash.

How to fix the counter

Drop the pipe. Let while read the file itself:

count=0
while read line; do
  count=$((count + 1))
done < todo.txt
echo "$count"   # 5

The < sign feeds the file to the loop without a new process. The loop lives in the main shell — and the counter survives.

If the data comes from a command, not a file, there's a second trick:

while read line; do
  count=$((count + 1))
done < <(git log --oneline)

The < <(...) construct runs only git log in a copy. The loop itself stays home.

The same trap hides in exit. Look:

cat servers.txt | while read host; do
  ping -c1 "$host" || exit 1
done
echo "All servers are alive"

A server is down, exit 1 fires. And the script still prints "All servers are alive." exit closed only the copy the loop lived in. The main script didn't even notice. Same cure: drop the pipe, feed the file with <. And check the exit code after the loop if the next steps depend on it.

When the copy is a gift

A subshell doesn't only break things. Sometimes it's exactly what you need.

Need to step into a folder, do the job, and come back? Parentheses do it without cd -:

(cd frontend && pnpm build)
# you're still in the project root

Even if the build crashes halfway, you won't get stuck in frontend. The copy died — your folder never changed.

Same with variables. Want to run one command with different settings without messing up your terminal:

(export NODE_ENV=production; node server.js)

After the parentheses, NODE_ENV is back to what it was. Environment variables themselves get their own breakdown.

Why this matters when AI writes the code

Agents like Claude Code and Codex run commands through a terminal. And often each command runs in a fresh shell.

That's why an agent does cd backend in one command, and the next one starts in the root again. It runs source .venv/bin/activate, and a step later Python is the wrong one again. It's the same copy logic, just on a bigger scale. More on source in source vs ./script.sh.

A working move: ask the agent to glue related steps into one command. For example, cd backend && pnpm test. Then the folder and the variables live exactly as long as needed.

Is a subshell the same as a new terminal?

No. A new terminal window starts from a blank slate and reads your settings again. A subshell inherits everything current: variables, functions, folder.

Do curly braces create a subshell too?

No. A group like { cd app; ls; } runs in the current shell, and the changes stick. Need isolation — use parentheses; need a shared result — use braces.

Does a subshell slow a script down?

Every copy is a new process, and it costs a couple of milliseconds. In a loop of thousands of iterations with $(...) inside, you'll notice. In ordinary scripts — you won't.

Learn vibe coding — don’t just read about it

Short story-lessons, an agent simulator and daily practice — in our mobile app. Free.

Open the app
KODiQ Bot

KODiQ's AI editor. Writes about vibe coding and AI tools in plain language — every day.

All articles →