Variables, quotes and command output
Assign, expand, quote, capture output and pass values to child processes.
tar -xzf bo-vars.tar.gz, which creates bo-vars/. SHA-256: f90cd70c53a90fb954e6f2eb2e4e837e4b6214c0a582ae185bc012de53c8f3b2This lesson teaches how a script stores values and hands them around: assigning a variable, reading it back, the two kinds of quotes, capturing a command's output, and passing values to the programs your script starts. Each of these has one rule that Bash neither enforces nor warns about, and breaking it produces a wrong result instead of an error. Every broken rule is run on the lab machine here, so that you recognise the symptom when it happens in one of your own scripts. The examples run in ~/bash-ops/vars.
Assign a value: nothing around the =
A variable is a name that holds a piece of text. You create or change one with name=value, and you read it with $name. There must be no space on either side of the =:
The first line stored hello and printed nothing (assignments are silent), and the second printed it. The third line failed because Bash splits a command line into words at spaces before it decides what the line means. greeting = "hello" is three words, so Bash took greeting as the name of a command to run with the arguments = and hello, found no such command, and reported command not found with status 127.
Names are letters, digits and underscores, and cannot start with a digit. Use lowercase names for your script's own variables. Uppercase is the convention for environment variables (later in this lesson) and for the settings the shell itself uses, such as PATH, HOME, USER and IFS. Reusing one of those by accident breaks things in ways that are hard to connect to the cause:
Whoever wrote that meant "the application's path", but PATH is the list of directories Bash searches for commands, so from then on not even ls can be found. A lowercase name such as app_path can never collide with the shell's own variables.
Read it back: $name and ${name}
Writing $name asks Bash to replace it with the variable's value before the command runs; this replacement is called expansion. Bash decides where the name ends by taking the longest run of letters, digits and underscores after the $. Braces, ${name}, mark the end explicitly:
$stage_host asked for a variable named stage_host. There is none, and Bash expanded it to nothing without any warning. ${stage}_host read stage and appended the text _host. The silence is the dangerous part: a misspelt or unset name becomes an empty string, and the command runs anyway with a piece missing. The lesson "Strict mode, honestly" shows how set -u turns that into an error; until then, use braces whenever text follows a name directly.
Double quotes, single quotes, and quoting every expansion
The Linux essentials lesson "Quoting, wildcards, history and aliases" showed the two kinds of quotes: inside double quotes Bash still expands $name and $( ) and keeps the result as one piece of text; inside single quotes nothing is expanded, $ included. In a script the choice decides what another program receives. Single quotes are for text that belongs to that program. awk is a small text-processing language that splits each line into fields at spaces; in an awk program, $1 means "the first field". Save these three lines of a web server log as access.log in the lesson directory:
203.0.113.10 - - [27/Sep/2026:09:14:02 +0000] "GET /health HTTP/1.1" 200 2198.51.100.7 - - [27/Sep/2026:09:14:05 +0000] "GET /login HTTP/1.1" 200 1432203.0.113.10 - - [27/Sep/2026:09:14:09 +0000] "POST /login HTTP/1.1" 401 38
Here is the same awk program in both kinds of quotes:
With single quotes, awk received { print $1 } and printed the client addresses. With double quotes, Bash expanded $1 first. At the prompt, $1 is the shell's own first argument, which is empty, so awk received { print } and printed every line whole. Nothing reported an error. Put programs meant for other tools (awk, sed, jq, a grep pattern containing $) in single quotes.
Double quotes around an expansion matter for a second reason, which the same Linux lesson showed with rm $file: without quotes, Bash splits the value at spaces, tabs and newlines, and each piece becomes its own argument (then it also expands wildcards in those pieces; "Quoting, word splitting and globbing" covers the full order). For file="q3 report.txt", rm $file removes q3 and report.txt if they exist, and leaves the file you meant. Quote every expansion, "$file" and "$(command)", unless you can say why this one must be split; step 4 of "Try this" lets you watch the difference with rmdir.
Capture a command's output with $( )
Command substitution, $(command), runs the command, takes what it writes to its standard output, removes every newline at the end, and puts the text where the $( ) was. That is how a script learns things it cannot know in advance: the date, the host name, a count.
date +%F prints the date as year-month-day and hostname prints the machine's name; both results landed in variables. The second step shows the newline rule: the newline between one and two was kept, and all three trailing newlines were removed, not only the last one. Backquotes around a command are the legacy spelling of the same thing; use $( ), as the Linux lesson advised. Here is a script that stamps a report line with who ran it, where and when:
#!/usr/bin/env bash# stamp.sh: one report line that says who ran it, where and when.run_by=$(whoami)host=$(hostname)today=$(date +%F)conf_count=$(find /etc -maxdepth 1 -name '*.conf' | wc -l)echo "$today $host $run_by: $conf_count .conf files directly in /etc"
Each $( ) runs once and its result is stored under a lowercase name. find /etc -maxdepth 1 -name '*.conf' lists the .conf files directly inside /etc; the pattern is in single quotes so that Bash passes it to find unchanged. | wc -l counts the lines of that list, so conf_count holds a number.
The count, 31, is what this lab machine has; yours will differ. Stamping who, where and when is the start of every useful log line and backup name, and later lessons build on it.
Pass values to other programs: export and NAME=value command
Every program a script starts is a child process, and it receives a copy of its parent's environment: a list of NAME=value pairs that programs read their settings from. A variable you assign in the shell is not part of that list until you export it:
bash -c '...' starts a child Bash that runs the quoted text, which makes it an easy way to see what a child receives. Before export, the child saw nothing. After it, the child got a copy. When the child changed its copy to prod, the parent kept staging: values flow down to children, never back up.
Often you want a setting for one command only. Put the assignment directly in front of the command, and it goes into that command's environment and nowhere else:
TZ is the time-zone variable that date and most other programs read, so the first date reported Tokyo's zone and offset (%Z %z asks for exactly those), and the second, without the prefix, reported the machine's own UTC. LOG_LEVEL=debug reached the child but left the shell unchanged. You will use this form again in later lessons, for example to point a single command at a different temporary directory.
deploy read the --token= argument of a process owned by nobody with ps), and what you type at an interactive prompt is saved in ~/.bash_history. Never echo a secret, and never run a script that holds one under bash -x: tracing prints every assignment with its value. An exported variable is better than an argument, because other users cannot read your environment (/proc/<pid>/environ is readable only by the same user and by root), but every child process inherits it, and anything that dumps its environment, such as env in a debug log, copies it. A file only you can read (mode 0600) or standard input is safer still. The course "Python for security automation" ranks every option in the lesson "Secrets: where they come from and where they leak".Try this
In a new directory, write backup-name.sh: set host from $(hostname) and today from $(date +%F), build archive="${host}-${today}.tar.gz", and end with echo "would write: $BACKUP_DIR/$archive". Make it executable, then:
1. Run BACKUP_DIR=/srv/backups ./backup-name.sh. Expect would write: /srv/backups/web01- followed by today's date and .tar.gz (with your host name).
2. Run ./backup-name.sh with no prefix. Expect would write: /web01-...: the unset variable became an empty string and the path moved to /. Name the rule from this lesson that explains it, and say what a real tar -czf "$BACKUP_DIR/$archive" would have tried to do.
3. Run export BACKUP_DIR=/srv/backups and then ./backup-name.sh in the same shell. Expect the path from step 1, and explain why the prefix is no longer needed.
4. Run mkdir "old logs" old logs, set dir="old logs", run rmdir $dir and then ls -1. Only 'old logs' is left. Recreate old and logs, run rmdir "$dir", and check that now only old and logs remain.
Takeaway
Quote every expansion ("$var", "$(command)"), keep your own variable names lowercase, and give a value to one command with NAME=value command instead of exporting it to everything the script starts.
awk "{ print $1 }" /var/log/nginx/access.log to list client addresses, but it prints every log line whole. What went wrong?$1, its own (empty) first argument, before awk starts. awk '{ print $1 }' passes the program through untouched.$1 is always the first field. It only became empty because Bash replaced it before awk ever saw it; awk never looks at the script's arguments.LOG_LEVEL is not set in your shell. You run LOG_LEVEL=debug ./sync.sh and then ./report.sh. Both scripts read $LOG_LEVEL. What does report.sh see?echo "this shell: [$LOG_LEVEL]" right after the prefixed command printed an empty value.LOG_LEVEL set, so it would see nothing either way.NAME=value command sets the variable for that one command. To give it to both, export it first or prefix both commands.target="old logs" and then rm -r $target. The directory holds old logs/, old/ and logs/. What happens?rm $file did exactly this, silently, with status 0.rm line splits it again.rm -r does not prompt for normal files and directories. It removed both directories without asking.