Education › DevOps › Stage 1: Foundations

Scripting with Bash & Python

Automate the repetitive work: files, APIs, JSON/YAML, and exit codes done right.

Beginner ~35 min read Module 4 of 17

Any task you do by hand three times is a script waiting to be written: rotating logs, calling an API, checking fifty servers, gluing two tools together in a pipeline. Bash is the glue that is always available; Python is what you move to when the logic gets real. This module teaches both to a production standard, which mostly means scripts that fail loudly, clean up after themselves, and can be run twice without damage.

After this module you can
  • Write Bash scripts that stop on errors using set -euo pipefail, quoting, and explicit exit codes
  • Use variables, conditionals, loops and functions in Bash without the classic quoting bugs
  • Process JSON with jq and call HTTP APIs from the shell
  • Decide when to switch from Bash to Python, and write a small Python CLI with argparse, subprocess and pathlib
  • Make automation idempotent, so that re-running it is always safe

A Bash script that fails properly

By default Bash carries on after a command fails, which is exactly wrong for automation: a failed cd followed by rm -rf * deletes the wrong directory. Start every script with the same three lines.

bash
#!/usr/bin/env bash
set -euo pipefail

# -e           exit as soon as a command fails
# -u           treat use of an unset variable as an error
# -o pipefail  a pipeline fails if ANY command in it fails, not just the last

The first line, the shebang, tells the kernel which interpreter to use. Make the file executable with chmod +x and run it as ./script.sh. Every command returns an exit code, 0 for success and non-zero for failure, and your script should too: CI systems, cron and && chains all decide what to do next from that number.

bash
#!/usr/bin/env bash
set -euo pipefail

usage() { echo "usage: $0 ENVIRONMENT" >&2; exit 2; }
[[ $# -eq 1 ]] || usage

env_name="$1"
workdir="$(mktemp -d)"
trap 'rm -rf "$workdir"' EXIT        # runs on success, failure, or Ctrl-C

echo "Deploying to ${env_name}, scratch space ${workdir}"

Two habits are visible there. Errors and usage messages go to stderr (>&2) so they do not pollute output that another program may be parsing. And trap ... EXIT registers cleanup that runs however the script ends, so temporary files never leak.

Variables, quoting, tests and loops

Assign with no spaces around =, read with $name or ${name}. The single most important Bash rule is: quote your variables. Unquoted, a value containing spaces is split into several words and any * in it is expanded against the filesystem.

bash
file="monthly report.txt"
rm $file            # WRONG: tries to delete 'monthly' and 'report.txt'
rm "$file"          # right

today="$(date +%F)"               # command substitution
region="${AWS_REGION:-eu-west-1}"  # default if unset or empty
: "${API_TOKEN:?API_TOKEN must be set}"   # abort with a message if missing

Use [[ ... ]] for tests. if does not test a boolean; it runs a command and checks its exit code, which means any command can be a condition.

bash
if [[ -f "$config" ]]; then echo "found"; fi      # -f file, -d dir, -z empty string
if [[ "$env_name" == "prod" && -z "${FORCE:-}" ]]; then
  echo "refusing to touch prod without FORCE=1" >&2
  exit 1
fi
if ! grep -q "^server" "$config"; then echo "no server line" >&2; fi

for host in web1 web2 web3; do
  ssh "$host" uptime || echo "failed: $host" >&2
done

while IFS= read -r line; do         # read a file line by line, safely
  echo "got: $line"
done < hosts.txt
Tip

Run shellcheck script.sh on everything you write. It catches unquoted variables, useless cat, broken tests and dozens of other mistakes, and explains each one. Add it to CI next to your other linters.

JSON and APIs from the shell

Almost every tool you automate can emit JSON: cloud CLIs, kubectl -o json, docker inspect, REST APIs. Do not parse it with grep and cut. jq is a small language for slicing JSON.

bash
curl -fsS https://api.example.com/v1/servers > servers.json

jq '.' servers.json                          # pretty-print
jq -r '.servers[].name' servers.json         # every name, raw (no quotes)
jq -r '.servers[] | select(.status == "down") | .name' servers.json
jq '.servers | length' servers.json          # count
jq -r '.servers[] | [.name, .ip] | @tsv' servers.json   # table for the shell

The curl flags matter in scripts. Without -f, curl exits 0 even when the server returns 500, and your script marches on with an error page in a variable. -sS hides the progress bar but still prints errors.

bash
response="$(curl -fsS --max-time 10 \
  -H "Authorization: Bearer ${API_TOKEN}" \
  -H 'Content-Type: application/json' \
  -d '{"name": "web4", "size": "small"}' \
  https://api.example.com/v1/servers)"

server_id="$(jq -r '.id' <<< "$response")"
echo "created ${server_id}"

For YAML, the equivalent tool is yq. In Python, YAML needs the third-party PyYAML package; always use yaml.safe_load, never plain yaml.load on input you do not control.

When to switch to Python

Bash is excellent at running programs and connecting them. It is poor at data structures, arithmetic, error handling and testing. A useful rule: move to Python when the script passes about fifty lines, needs a list of dictionaries, has to retry or handle errors selectively, or will be maintained by someone else.

python
#!/usr/bin/env python3
"""Report certificates that expire soon."""
import argparse
import json
import sys
from pathlib import Path


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("inventory", type=Path, help="JSON file of certificates")
    parser.add_argument("--days", type=int, default=30)
    args = parser.parse_args()

    certs = json.loads(args.inventory.read_text())
    expiring = [c for c in certs if c["days_left"] < args.days]
    for cert in sorted(expiring, key=lambda c: c["days_left"]):
        print(f"{cert['host']}: {cert['days_left']} days", file=sys.stderr)
    return 1 if expiring else 0


if __name__ == "__main__":
    sys.exit(main())

Notice the same conventions as in Bash: a shebang, arguments parsed properly (you get --help for free), diagnostics on stderr, and a meaningful exit code returned through sys.exit. That exit code is what lets a pipeline or a monitoring check act on the result.

To run other programs, use subprocess.run with a list of arguments. Passing a list means no shell is involved, so a filename containing spaces or a ; cannot be misinterpreted. check=True raises an exception when the command fails, which is Python's equivalent of set -e.

python
import subprocess

result = subprocess.run(
    ["kubectl", "get", "pods", "-n", "shop", "-o", "json"],
    capture_output=True, text=True, check=True, timeout=30,
)
print(len(result.stdout), "bytes of JSON")
Watch out

Avoid shell=True and os.system, especially with any value that came from a user, a file or an API. Building a command string from outside input is how command-injection vulnerabilities are written.

Calling APIs with retries

Networks fail briefly all the time, so a script that calls an API needs a timeout on every request and a bounded retry for failures that are worth retrying: connection errors and 5xx or 429 responses. A 4xx means your request is wrong, and retrying it will not help. The widely used requests library keeps this short.

python
import os
import time

import requests

TOKEN = os.environ["API_TOKEN"]          # KeyError if missing: fail early


def get_json(url: str, attempts: int = 4) -> dict:
    for attempt in range(attempts):
        try:
            resp = requests.get(
                url, headers={"Authorization": f"Bearer {TOKEN}"}, timeout=10
            )
            if resp.status_code < 500 and resp.status_code != 429:
                resp.raise_for_status()   # raises on 4xx: do not retry those
                return resp.json()
        except requests.ConnectionError:
            pass
        time.sleep(2 ** attempt)          # 1s, 2s, 4s, 8s
    raise RuntimeError(f"gave up on {url} after {attempts} attempts")

Secrets come from the environment or a secret manager, never from the source file. Install third-party packages into a virtual environment (python3 -m venv .venv) and record them in requirements.txt, so the script runs the same on your laptop and in CI.

Idempotency: safe to run twice

Automation gets re-run: after a failure halfway, by a scheduler, by a colleague who was not sure it worked. An idempotent script produces the same end state however many times it runs. The technique is to describe the state you want and check before you act, instead of blindly performing an action.

FragileIdempotent
mkdir /srv/appmkdir -p /srv/app
echo "line" >> filegrep -qxF "line" file || echo "line" >> file
ln -s target linkln -sfn target link
useradd appid app >/dev/null 2>&1 || useradd app
rm filerm -f file

This idea is the foundation of the tools later in this track. Ansible modules, Terraform resources and Kubernetes manifests are all declarations of desired state that can be applied repeatedly. Also add a --dry-run flag to anything destructive, so you can see what it would do before it does it.

Hands-on practice

Automate a real check, twice

  1. Install shellcheck and jq. Write check_sites.sh that reads URLs from a file and prints the HTTP status code and total time for each, using curl -o /dev/null -s -w.
  2. Give it set -euo pipefail, a usage message on stderr, and a trap that removes a temp directory. Make it exit 1 if any site did not return 200.
  3. Run shellcheck on it and fix every finding. Then prove the exit code works: ./check_sites.sh urls.txt && echo OK || echo FAILED.
  4. Call a public JSON API with curl -fsS and use jq to extract one field from every element of an array, as tab-separated output.
  5. Rewrite the site checker in Python with argparse, a --timeout option, per-request timeouts, and the same exit-code behaviour.
  6. Write a small setup script (create a directory, append a line to a config file, create a symlink) and run it three times in a row. Fix it until the third run changes nothing and prints no errors.
Cheat sheet

Scripting with Bash & Python — at a glance

Main things to focus on

  • Start every Bash script with set -euo pipefail. Without it, failures are ignored.
  • Quote every variable expansion: "$var". Unquoted variables are the number one Bash bug.
  • Exit code 0 is success; return a meaningful non-zero code on failure. Diagnostics go to stderr.
  • curl -fsS in scripts, always with a timeout. Without -f an HTTP 500 looks like success.
  • Parse JSON with jq, never with grep.
  • Past about 50 lines or once you need real data structures, use Python.
  • subprocess.run([...], check=True) with a list of arguments. No shell=True.
  • Idempotent: check the state, then act. The script must be safe to run twice.

Bash safety and structure

#!/usr/bin/env bashShebang: find bash on the PATH
set -euo pipefailExit on error, on unset variables, on any failure in a pipeline
trap 'rm -rf "$tmp"' EXITCleanup that runs however the script ends
echo "message" >&2Write to stderr
exit 1Finish with a failure code
cmd1 && cmd2 || cmd3cmd2 if cmd1 succeeds; cmd3 if that chain fails
shellcheck script.shLint a script for common bugs

Variables and expansion

name="value"Assign (no spaces around =)
"$(command)"Capture a command's output
"${VAR:-default}"Use default if unset or empty
: "${VAR:?message}"Abort with message if unset or empty
$1 $2 "$@" $#Positional args, all args, arg count
$?Exit code of the last command

Tests and loops

[[ -f FILE ]] / [[ -d DIR ]]File exists / directory exists
[[ -z "$s" ]] / [[ -n "$s" ]]String is empty / not empty
[[ "$a" == "$b" ]]String equality
[[ "$n" -gt 5 ]]Integer comparison: -eq -ne -lt -le -gt -ge
for x in a b c; do ...; doneLoop over words
while IFS= read -r line; do ...; done < FILERead a file line by line

jq and curl

jq -r '.items[].name'A field from every array element, unquoted
jq '.items[] | select(.ok == false)'Filter elements
jq '.items | length'Count
jq -r '.[] | [.a, .b] | @tsv'Tab-separated rows for shell loops
curl -fsS --max-time 10 URLFail on HTTP errors, quiet, bounded time
curl -H "Authorization: Bearer $TOKEN" URLAuthenticated request

Python for ops

argparse.ArgumentParser()Command-line arguments with --help for free
sys.exit(main())Return the script's exit code
subprocess.run([...], check=True, capture_output=True, text=True)Run a program safely and capture its output
Path(p).read_text() / .exists() / .glob('*.log')Files with pathlib
json.loads(s) / json.dumps(obj, indent=2)Parse and produce JSON
os.environ["NAME"] / os.environ.get("NAME", "x")Required / optional environment variable
python3 -m venv .venv && . .venv/bin/activateIsolated environment for dependencies

Common pitfalls

  • Omitting set -e, so a failed cd is followed by a destructive command in the wrong directory.
  • Leaving variables unquoted, which breaks on the first filename that contains a space.
  • Using curl without -f and treating an HTTP error page as a successful response.
  • Hard-coding tokens and passwords in the script instead of reading them from the environment.
  • Building shell command strings from input in Python with shell=True, which invites injection.
  • Writing a script that only works on the first run because it never checks the current state.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →