Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Intermediate+150 XP

Linux and Shell Scripting for Data Engineers

The commands and scripts every DE uses daily — files, processes, cron, log analysis, and bash — taught through one running investigation, not a command dump.

70 min August 2026
// Part 01 — Why Linux Matters for Data Engineers

Every Data Pipeline Runs on Linux

Almost every server that runs a data pipeline — cloud VMs, Docker containers, Kubernetes pods, Airflow workers, Spark executors — runs Linux. When a pipeline fails at 3 AM, you SSH into a Linux box and diagnose it. When a disk fills up and kills a pipeline, you find the culprit with Linux commands. When you need to quickly inspect a 10 GB log file without loading it into Python, you use Linux tools that do it in seconds.

Linux proficiency for a data engineer is not about memorising every command. It is about being comfortable in a terminal, knowing which tools solve which problems, and being able to write shell scripts that automate the repetitive operational tasks that surround every data pipeline. This module is built around a single thread you will follow the whole way through: a real orders pipeline for a company called FreshCart, and every tool introduced along the way is one you will actually use on it — not a detached command reference you have to mentally translate into a real situation later.

What this module builds toward

By the end, you will have written a complete, production-grade bash wrapper script for the FreshCart orders pipeline — piece by piece, understanding every line — and diagnosed a real 6:47 AM pipeline failure end to end using nothing but the commands from this module.

01Navigation and files
Moving around, finding files, and reading disk usage.
02Permissions
The rwx model, and diagnosing "Permission denied" in seconds.
03Text processing
grep, awk, sed, cut, sort, uniq — the DE log-analysis toolkit.
04Process management
ps, top, kill, nohup, background jobs, signals.
05File transfer
scp, rsync, curl, aws s3 — moving data between machines.
06Cron scheduling
Writing and debugging crontabs, the fastest scheduler you have.
07Bash scripting
Variables, conditionals, loops, functions — built up incrementally.
08Environment and config
Why cron breaks scripts that work fine when you run them by hand.
// Part 02 — Navigation and Finding Files

Moving Around and Finding What You Need

You have just been given SSH access to pipeline-01, the server that runs FreshCart's nightly orders pipeline. The first thing any data engineer does on an unfamiliar server is get their bearings — where am I, what is here, and how big is it.

Where am I, and what's here

Command
pwd
output
/home/pipeline_user

pwd ("print working directory") always tells you exactly where you are — the single most useful command to run the moment you feel lost. Next, move to where the pipeline actually lives and see what's there.

Command
cd /data/pipelines
ls -lah
output
total 24K
drwxr-xr-x 5 pipeline_user data_team 4.0K Mar 17 06:00 .
drwxr-xr-x 3 root          root      4.0K Jan  4  2026 ..
-rwxr-xr-x 1 pipeline_user data_team  892 Mar 17 06:00 run_orders.sh
drwxr-x--- 2 pipeline_user data_team 4.0K Mar 17 06:14 logs
drwxr-x--- 2 pipeline_user data_team 4.0K Mar 16 23:00 pipeline

-l gives the long listing (permissions, owner, size, date — all things you will need constantly), -a shows hidden files (anything starting with a dot), and -h makes sizes human-readable (4.0K instead of 4096). This three-flag combo is worth memorising as one habit: ls -lah is the default way any experienced engineer looks at a directory.

CommandMoves to
cd /data/pipelinesan absolute path — always the same place, regardless of where you started
cd ../logsa relative path — one level up, then into logs
cd ~your home directory
cd -wherever you just were — the previous directory

Finding files without knowing exactly where they are

You need last night's log file, but you don't remember the exact path.find searches a directory tree by name, age, or size — and it is almost always faster than clicking through folders.

Command
find /data -name "orders_*.log" -mtime -1
output
/data/pipelines/logs/orders_20260317.log

-name matches a filename pattern; -mtime -1 means "modified in the last 1 day" — the minus sign means less than. A few more variations you will reach for constantly:

Command
find /data -size +1G                         # files larger than 1 GB
find /data -empty                            # empty files — often a sign something failed
find /tmp -name "*.tmp" -mtime +7 -delete    # delete .tmp files older than 7 days
⚠️ Important
Notice +7 above means more than 7 days, the opposite of the -1 you just saw. find's plus/minus convention trips up almost everyone once — plus is "more than", minus is "less than", no sign at all is "exactly". Get this backwards on a -delete command and you can delete far more (or far less) than you meant to. Always run the search without -delete first and read the file list before adding it.

Viewing file content without opening an editor

Command
tail -n 20 /data/pipelines/logs/orders_20260317.log
output
2026-03-17 06:00:04 INFO  Starting orders_pipeline
2026-03-17 06:00:05 INFO  Connected to database
2026-03-17 06:02:41 INFO  Batch 1 complete: 10000 rows
2026-03-17 06:04:58 INFO  Batch 2 complete: 10000 rows
2026-03-17 06:07:12 WARNING DEBUG_MODE=true detected — writing full row dump

That last line is worth remembering — it becomes important later in this module. tail shows the end of a file (the default is 10 lines; -n 20 asks for 20). Its most useful mode for a live pipeline is -f, which follows the file and streams new lines as they are written — the command you leave running in a terminal while a pipeline executes:

Command — leave this running while a pipeline is executing
tail -f /data/pipelines/logs/orders_20260317.log | grep --line-buffered ERROR

This follows the log in real time but only prints lines containing ERROR — everything else scrolls by silently. head is the mirror image of tail, showing the first N lines instead of the last — useful for checking a CSV's header without printing the whole file with cat, which for a 10 GB file would flood your terminal and do nothing useful.

How much disk space is actually left

Command
df -h /data
output
Filesystem  Size  Used  Avail  Use%  Mounted on
/dev/sdb1   500G  460G    40G   93%  /data

df ("disk free") reports space at the filesystem level — this is the first command to run any time a pipeline behaves strangely, because a nearly-full disk causes symptoms that look like almost anything else: writes hang, processes stall, jobs that used to take 5 minutes suddenly run for hours. Once you know a disk is nearly full, du ("disk usage") tells you what is taking the space:

Command
du -sh /data/* | sort -rh | head -5
output
312G  /data/raw
 92G  /data/processed
 34G  /data/logs
  8G  /data/tmp
  2G  /data/pipelines

-s summarises each argument to one total instead of listing every file inside it, and piping through sort -rh (reverse, human-readable-numeric) puts the biggest consumer first. You can repeat this one level deeper on whichever directory turns out to be the culprit — that exact drill-down is exactly how the 7 AM incident later in this module gets solved.

⌨️
Try this yourself
Pick any directory on a machine you have access to and run du -sh * | sort -rh | head -10 inside it. You will almost always find the space is concentrated in one or two places you didn't expect — that instinct, "check du before guessing," is worth more than memorising every flag.
// Part 03 — File Permissions

The rwx Model — Reading and Fixing "Permission Denied"

A data engineer who does not understand permissions will spend hours debugging "Permission denied" errors that take seconds to fix once the model is understood. Every file and directory on Linux carries exactly this information:

Command
ls -lah /data/pipelines/run_orders.sh
output
-rwxr-x--- 1 pipeline_user data_team 892 Mar 17 06:00 run_orders.sh

Break the permission string apart and it reads as four independent pieces:

-rwxr-x---
Type
- file · d directory · l symlink
Owner
read + write + execute
Group
read + execute only
Others
no access at all

Each of the three access groups (owner, group, others) carries the same three possible permissions: r (read — can view contents, or list a directory), w (write — can modify a file, or create files inside a directory), and x (execute — can run a file as a program, or enter a directory with cd). This file is rwx for the owner, r-x for the group, and nothing at all for everyone else.

Changing permissions with chmod

Each permission has a numeric value — r=4, w=2, x=1 — and you add them together per group. rwx is 4+2+1=7, r-x is 4+1=5, and chmod takes one digit per group in owner-group-others order:

chmodMeaningTypical use
755rwxr-xr-xscripts everyone should be able to run
644rw-r--r--config files — readable by all, writable only by owner
600rw-------secrets: API keys, passwords, credentials files
700rwx------private directories — owner only
Command
chmod +x run_orders.sh        # add execute permission, keep everything else
chmod 600 db_credentials.env  # secrets: owner read/write, nobody else anything
🎯 Pro Tip
chmod +x is worth knowing as its own idiom, separate from the numeric form — it adds execute permission without touching read/write bits you already have set, which is exactly what you want the moment you write a new shell script and immediately try to run it.

Diagnosing a permission error, systematically

When you see Permission denied, resist the urge to guess — three commands tell you exactly what's wrong:

Command
ls -lah /data/output/orders.parquet   # what permissions does the FILE have?
id                                    # what user and groups am I actually in?
stat /data/output/orders.parquet      # full detail: owner, group, exact mode
output
-rw-r----- 1 other_service data_team 4.2G Mar 17 06:12 orders.parquet
uid=1001(pipeline_user) gid=1002(data_team) groups=1002(data_team)

Reading this output: the file is owned by other_service, not pipeline_user — but pipeline_user is a member of the data_team group, and the group permission is r-- (read only, no write). That fully explains a "permission denied" on any attempt to write to this file — the fix is either to change the group permission (chmod g+w) or the ownership entirely:

Command
chown pipeline_user:data_team orders.parquet     # change owner and group
chmod -R 750 /data/secrets/                      # recursively set 750 on a whole tree
// Part 04 — grep

grep — Searching Logs Without Opening Them

Linux text processing tools are the fastest way to investigate pipeline logs and answer quick questions without writing a line of Python. A data engineer who knows grep, awk, sed, and cut can diagnose most pipeline failures in minutes, straight from the terminal.

The basics

Command
grep "ERROR" orders_20260317.log
output
2026-03-17 06:41:02 ERROR Connection to database timed out after 30s
2026-03-17 06:41:33 ERROR Retry 1/3 failed

The plainest possible use — every line containing the literal text ERROR. Three flags cover most of what you need day to day:

FlagEffect
-icase-insensitive — matches ERROR, error, Error
-nshow the line number of each match
-cprint only a count of matching lines, not the lines themselves
-vinvert the match — show lines that do NOT contain the pattern

Context lines — seeing what happened around an error

A single matching line rarely tells the whole story. You almost always want to see what happened immediately before or after it too:

Command
grep -B 2 -A 5 "ERROR" orders_20260317.log
output
2026-03-17 06:40:58 INFO  Attempting database connection...
2026-03-17 06:40:58 INFO  Connection pool: 5/5 in use
2026-03-17 06:41:02 ERROR Connection to database timed out after 30s
2026-03-17 06:41:02 INFO  Retry scheduled in 5s
2026-03-17 06:41:07 INFO  Retrying database connection...
2026-03-17 06:41:33 ERROR Retry 1/3 failed
2026-03-17 06:41:33 INFO  Retry scheduled in 10s

-B 2 shows 2 lines before each match, -A 5 shows 5 after — instantly turning one alarming line into the full story: the connection pool was maxed out right before the timeout. -C 5 is the shorthand for equal context on both sides.

Regex patterns for real log formats

Command
grep -E "ERROR|CRITICAL" orders_20260317.log      # either word, extended regex
grep -E "order_id=[0-9]+" orders_20260317.log     # order_id followed by digits
grep -E "^2026-03-17 06:4" orders_20260317.log    # lines starting with this timestamp

-E turns on extended regular expressions, which is what lets | mean "or" and [0-9]+ mean "one or more digits." Without -E, grep's basic mode requires escaping these characters, which is easy to get wrong under pressure — reach for -E by default.

Searching many files, and scripting a check

Command
grep -r "CRITICAL" /var/log/pipelines/           # search every file, recursively
grep -l "ERROR" /var/log/pipelines/*.log          # list which FILES contain it (not lines)
output
/var/log/pipelines/orders_20260317.log
/var/log/pipelines/inventory_20260317.log

grep -q (quiet — no output at all, just a pass/fail exit code) is what makes grep useful inside a script's own logic, not just for reading logs yourself:

Command
if grep -q "CRITICAL" orders_20260317.log; then
    echo "Critical error found — alerting team"
fi
⌨️
Try this yourself
Take any log file you have (even a small one) and run grep -c "ERROR" file.log then compare it to grep -c "WARNING" file.log. That single-command comparison is often the first thing worth checking when a pipeline "seemed fine" but something downstream looks wrong.
// Part 05 — awk and sed

awk for Columns, sed for Find-and-Replace

Where grep finds lines, awk works with columns inside those lines — exactly what you need for a CSV or any consistently-delimited text.

awk — column extraction and calculation

Command — orders.csv has columns: id, store, amount, status
awk -F',' '{print $3}' orders.csv | head -5
output
amount
24.99
89.50
12.00
156.75

-F',' sets the field delimiter to a comma, and $3 refers to the third column ($1, $2… and $0 for the whole line). awk becomes genuinely powerful once you add a condition or a running calculation:

Command — total revenue across the whole file, skipping the header
awk -F',' 'NR>1 {sum += $3} END {print "Total:", sum}' orders.csv
output
Total: 48291.35

NR is the current line number, so NR>1 skips the header row; sum += $3 runs on every remaining line; END marks a block that runs once, after every line has been processed. This one-liner is doing the same thing a full Python script with a CSV reader and an accumulator variable would do — in a single terminal command.

Command — count how many orders have each status
awk -F',' 'NR>1 {counts[$4]++} END {for (s in counts) print s, counts[s]}' orders.csv
output
delivered 412
cancelled 18
pending 31

sed — find, replace, and delete in a text stream

sed ("stream editor") is built around one core operation: substitution.

Command
sed 's/old-db-host/new-db-host/g' config.yaml

s/find/replace/g — substitute, and g means every occurrence per line, not just the first. Printed to the screen like this, sed changes nothing — it only shows you what the result would look like. Add -i to actually modify the file in place:

Command
sed -i.bak 's/old-db-host/new-db-host/g' config.yaml
⚠️ Important
-i alone overwrites the file with no backup and no confirmation. The habit worth building is -i.bak instead — it makes the same edit but keeps a config.yaml.bak copy of the original, so a substitution that goes wrong is one mv away from undone, not gone.

Two more sed patterns come up constantly around data files:

Command
sed '1d' orders.csv | wc -l              # strip the header, then count remaining rows
sed -n '10,20p' pipeline.log              # print ONLY lines 10 through 20

cut — when you just need columns, nothing more

For simple column extraction with no calculation involved, cut reads more clearly than awk:

Command
cut -d',' -f1,3 orders.csv | head -3
output
id,amount
1001,24.99
1002,89.50
// Part 06 — Combining Tools

sort, uniq, and Pipes — The Real Power of the Shell

Every tool covered so far does exactly one thing. The reason the Linux shell is genuinely powerful for a data engineer is not any single command — it's that | (pipe) sends the output of one command straight into the input of the next, letting you chain small, simple tools into something that would otherwise take a real script to write.

sort and uniq, individually

Command
sort -t',' -k3 -rn orders.csv | head -3
output
4821,ST003,899.00,delivered
1052,ST001,650.25,delivered
3390,ST002,512.10,cancelled

-t',' sets the field separator, -k3 sorts by the 3rd field, -rn reverse-numeric — so this shows the highest-value orders first. uniq has one sharp edge worth knowing before you use it: it only removes adjacent duplicate lines, which is exactly why it is almost always used right after sort.

The pattern: cut | sort | uniq -c | sort -rn

This exact four-stage pipe is one of the highest-value one-liners in the entire module — it turns any column into a ranked frequency count:

Command — which order status appears most often?
cut -d',' -f4 orders.csv | sort | uniq -c | sort -rn
output
  412 delivered
   31 pending
   18 cancelled

Read left to right: cut pulls out just the status column, sort groups identical values next to each other (required before uniq can work), uniq -c collapses each run of duplicates into one line prefixed with its count, and the final sort -rn puts the highest count first. Four tools, each doing one job, composed into an answer that would otherwise mean opening the file in pandas.

Command — the same pattern, a different question
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -10   # top 10 IPs hitting a server
⌨️
Try this yourself
Take any CSV or log file you have and run cut -d',' -f<N> file.csv | sort | uniq -c | sort -rn on whichever column looks categorical (a status, a country code, a type). This single pattern answers "what's the distribution of this column" faster than almost any other approach.
// Part 07 — Process Management

Processes, Signals, and Running Things in the Background

Data pipelines are processes. Understanding how Linux manages them lets you run a pipeline in the background, watch its resource usage, and kill a stuck job cleanly instead of guessing.

Finding and inspecting a running process

Command
ps aux | grep orders_pipeline
output
pipeline 18734 98.2  4.1  python3 orders_pipeline.py --date 2026-03-17

ps aux lists every process; piping through grep narrows it to the one you care about. That 98.2 is the CPU percentage — worth watching, because a healthy pipeline batch job is usually well under 100%, and a number pinned there for a long stretch is a signal something is spinning instead of progressing. To see exactly how long it has actually been running:

Command
ps -p 18734 -o pid,etime,pcpu,pmem,cmd
output
PID    ELAPSED  %CPU  %MEM  CMD
18734  02:14:32  98.2   4.1  python3 orders_pipeline.py

Two hours and fourteen minutes for a job that should finish in thirty is a real signal, not a coincidence — this is the exact reading that kicks off the diagnosis in the Real World section later in this module.

Killing a process — and why the signal you send matters

SignalCommandWhat happens
SIGTERM (15)kill 18734graceful — the process can catch this, finish its current batch, and clean up
SIGKILL (9)kill -9 18734immediate — cannot be caught or ignored; no cleanup; files being written may be corrupted
SIGINT (2)Ctrl+Csame as SIGTERM in practice — Python raises KeyboardInterrupt
⚠️ Important
Always send SIGTERM first and only escalate to SIGKILL if the process is still alive a few seconds later. A well-written pipeline catches SIGTERM and flushes its write buffer before exiting; SIGKILL gives it no chance to do that, and a parquet file being written when SIGKILL arrives is left truncated and unreadable.
Command — the correct kill sequence in practice
kill 18734              # SIGTERM — ask nicely
sleep 5
kill -0 18734 2>/dev/null && kill -9 18734    # still alive? force it

kill -0 is a neat trick worth knowing on its own — it sends no signal at all and only checks whether the process still exists, which is exactly what you need before deciding whether escalation to SIGKILL is actually necessary.

Running something that survives you logging out

Command
nohup python3 pipeline.py > output.log 2>&1 &
echo $!
output
[1] 21044
21044

Four things are happening on that first line: nohup means the process keeps running even after you close the SSH session; > output.log redirects normal output to a file; 2>&1 sends error output to that same place instead of the screen; and the trailing & runs it in the background so your terminal is immediately free again. echo $! prints the process ID of that last background command — worth capturing immediately if you'll need to check on or kill it later.

// Part 08 — Moving Data Between Machines

scp, rsync, curl, and S3 — Choosing the Right Tool

Data engineering involves constant movement of files — from a source server to a data lake, between cloud regions, from an external partner's SFTP drop to a processing node. Four tools cover almost every case, and the right one depends entirely on what you're actually doing.

ToolUse it when
scpa quick one-off copy of a single file or small folder over SSH
rsyncsyncing a large directory repeatedly — it only transfers what changed
curl / wgetpulling from an HTTP(S) URL or calling an API
aws s3 cp/syncmoving data to or from an S3 bucket specifically

scp — simple and immediate

Command
scp orders.csv user@pipeline-01:/data/landing/
scp -i ~/.ssh/pipeline_key.pem orders.csv ec2-user@54.1.2.3:/data/

rsync — the right choice for anything repeated or large

rsync only transfers files that have actually changed, which is the difference between a nightly sync taking 40 minutes versus 40 seconds once most of the data is already in place.

Command
rsync -avz --dry-run /data/local/ user@server:/data/remote/    # preview first — nothing moves
rsync -avz /data/local/ user@server:/data/remote/               # then actually run it

-a is archive mode (recursive, preserves permissions and timestamps), -v is verbose, -z compresses during transfer. Always run with --dry-run once first on anything you haven't run before — it shows exactly what would be transferred, without transferring anything, which is a cheap safety check before a sync that touches thousands of files.

curl — for APIs and HTTP downloads

Command
curl -H "Authorization: Bearer $API_TOKEN" https://api.example.com/data > data.json

Moving data to and from S3

Command
aws s3 cp orders.csv s3://freshcart-data/raw/orders.csv
aws s3 sync /data/local/ s3://freshcart-data/processed/

cp moves one object; sync behaves like rsync for an entire prefix — only uploading what's new or changed.

// Part 09 — Cron Scheduling

Cron — Automating a Pipeline on a Schedule

For a pipeline that doesn't yet warrant a full orchestration tool like Airflow, cron is the fastest, most reliable way to schedule it. And even once you are using Airflow, its schedule strings use this exact same syntax — so cron is never wasted knowledge.

Reading cron syntax

06***/data/pipelines/run_orders.sh
minute
0–59
hour
0–23
day of month
1–31
month
1–12
day of week
0–7 (Sun=0 or 7)

A * means "any value" for that field. So this reads: minute 0, hour 6, any day of month, any month, any day of week — every day at 6:00 AM, exactly.

ScheduleMeaning
0 6 * * *every day at 6:00 AM
0 8 * * 1-5every weekday at 8:00 AM
*/15 * * * *every 15 minutes
0 */6 * * *every 6 hours
0 3 1 * *3:00 AM on the 1st of every month

Editing and reading the crontab

Command
crontab -e      # edit your crontab (opens in $EDITOR)
crontab -l      # list what's currently scheduled

Three habits every production crontab entry needs

Command — the difference between a fragile entry and a production one
# Fragile — will likely fail silently under cron:
0 6 * * * run_orders.sh

# Production-ready:
0 6 * * * /data/pipelines/run_orders.sh >> /var/log/pipelines/orders.log 2>&1

Three specific things changed: an absolute path to the script (cron does not use your normal shell's PATH — more on exactly why in Part 12), and output redirected to a real log file so a failure leaves a trace instead of vanishing. This exact gap — a script that runs fine by hand but fails silently under cron — is common enough that it gets its own dedicated explanation later in this module.

Debugging a cron job that "isn't running"

Command
sudo systemctl status cron              # is the cron daemon even running?
grep CRON /var/log/syslog | tail -20     # what has cron actually attempted?
⌨️
Try this yourself
Write a one-line cron entry that appends the current date to a file every minute — * * * * * date >> /tmp/cron_test.log — and check back in a few minutes. Seeing it actually work end to end, once, is worth more than reading the syntax table twice.
// Part 10 — Bash Scripting Fundamentals

Variables, Conditionals, and Loops

Bash scripts wrap a pipeline with the operational logic Python alone doesn't handle well: checking preconditions, logging, alerting on failure, preventing duplicate runs. Every production pipeline is wrapped in at least a basic bash script — the rest of this module builds one, piece by piece.

Variables

Command
name="FreshCart"
today=$(date +%Y-%m-%d)      # command substitution — capture a command's output
echo "Company: $name, today: $today"
output
Company: FreshCart, today: 2026-03-17

$(...) runs a command and substitutes its output — the single most-used piece of bash syntax in real pipeline scripts, since almost every script needs "today's date" or "the result of some check" captured into a variable.

Conditionals

Command
if [[ -f "/data/orders.csv" ]]; then
    echo "File exists"
else
    echo "File is missing"
fi
TestChecks
[[ -f path ]]a regular file exists
[[ -d path ]]a directory exists
[[ "$a" == "$b" ]]string equality
[[ $a -gt $b ]]numeric comparison (also -lt -ge -le -eq -ne)

One conditional worth knowing specifically: testing whether a command itself succeeded, which is how a script checks its own dependencies before doing real work.

Command
if psql "$DATABASE_URL" -c "SELECT 1" > /dev/null 2>&1; then
    echo "Database is reachable"
else
    echo "Cannot reach database — aborting"
    exit 1
fi

Loops

Command — process a fixed list of stores
stores=("ST001" "ST002" "ST003")
for store in "${stores[@]}"; do
    echo "Processing store: $store"
done
Command — retry with backoff, a genuinely common pipeline pattern
retry=0
max_retries=3
while [[ $retry -lt $max_retries ]]; do
    if python3 pipeline.py; then
        echo "Success on attempt $((retry+1))"
        break
    fi
    retry=$((retry+1))
    echo "Attempt $retry failed — retrying in $((2**retry))s"
    sleep $((2**retry))
done

$((2**retry)) is exponential backoff written directly in bash arithmetic — 2, 4, 8 seconds — the same pattern you'd reach for in Python, expressed in the shell.

Functions

Command
check_disk_space() {
    local path="${1:-/data}"
    local min_gb="${2:-10}"
    local free_gb
    free_gb=$(df -BG "$path" | awk 'NR==2 {print $4}' | tr -d 'G')

    if [[ $free_gb -lt $min_gb ]]; then
        echo "ERROR: only ${free_gb}GB free at $path (need ${min_gb}GB)"
        return 1
    fi
    return 0
}

check_disk_space /data 10 || exit 1

local keeps a variable scoped to the function instead of leaking into the rest of the script — worth using by habit inside every function you write. Notice this function also reuses the exact df + awk combination from Part 02 — a small sign of how a handful of core tools recombine into everything else in this module.

// Part 11 — String Manipulation and Dates

Parsing Filenames and Computing Dates in Pure Bash

Pipeline scripts constantly need to pull a piece out of a filename, or compute "yesterday" for a backfill — bash can do both without calling out to Python.

Command
filename="/data/orders_2026_03_17.csv"

echo "${filename##*/}"     # orders_2026_03_17.csv  — strip everything up to the last /
echo "${filename%.*}"      # /data/orders_2026_03_17 — strip the extension
echo "${filename##*.}"     # csv — just the extension

##*/ and %.* look cryptic at first but follow one rule: # strips from the front, % strips from the back, and doubling the symbol (##, %%) makes it greedy (match as much as possible) instead of matching the shortest possible piece.

Command
today=$(date +%Y-%m-%d)
yesterday=$(date -d 'yesterday' +%Y-%m-%d)     # Linux
# yesterday=$(date -v-1d +%Y-%m-%d)            # macOS — different flag, same result
log_suffix=$(date +%Y%m%d_%H%M%S)
output
today: 2026-03-17
yesterday: 2026-03-16
log_suffix: 20260317_081432
🎯 Pro Tip
Data pipelines default to processing yesterday's data far more often than "today's" — a batch job that runs at 6 AM is finishing off the previous full day, not the day that just started. date -d 'yesterday' (or -v-1d on macOS) is worth knowing cold for exactly this reason.
// Part 12 — Environment and Configuration

Why a Script That Works By Hand Can Fail Under Cron

This is the single most common "it works on my machine" problem a data engineer hits, and it has one root cause: cron runs your script in aminimal environment, not the rich one your interactive shell sets up for you.

Command — run this both by hand and inside a cron job, compare
echo $PATH
output
# In your interactive shell:
/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/home/pipeline_user/.local/bin

# Under cron:
/usr/bin:/bin

That difference is the whole problem. If your script calls python3 and it happens to live in /usr/local/bin (very common), your interactive shell finds it instantly — but cron's minimal PATH does not include that directory at all, so the exact same script fails under cron with "command not found," even though it just ran perfectly when you tested it by hand two minutes earlier.

CauseFix
Minimal PATHuse absolute paths everywhere: /usr/local/bin/python3, not python3
.bashrc never loadedsource a dedicated env file explicitly inside the script
Working directory is $HOME, not the script’s foldercd "$(dirname "$0")" at the top of the script, or use absolute paths
Output has nowhere to goalways redirect: script.sh >> /var/log/job.log 2>&1
Command — loading environment variables safely inside a script
set -a              # auto-export every variable that gets set below
source /etc/pipeline_environment
set +a              # stop auto-exporting

This pattern — set -a, source the file, set +a — is the cleanest way to load a whole file of configuration into a script's environment without hand-writing an export line for every single variable in it.

// Part 13 — Building the Production Script

Assembling Everything Into One Real Pipeline Wrapper

Every tool in this module so far has been a piece. Now they come together — the same FreshCart orders pipeline, wrapped in a real production-grade bash script, built up one addition at a time so each piece is understood before the next one lands on top of it.

Step 1 — the non-negotiable first line

orders_pipeline.sh — start here, always
#!/usr/bin/env bash
set -euo pipefail

This is the correct second line of every production bash script, without exception. -e exits immediately the moment any command fails, instead of bash's default of quietly continuing to the next line. -u turns a typo like $DATABSE_URL into an immediate, clear error instead of silently substituting an empty string. -o pipefail makes a pipe fail if any stage of it fails — without it, bad_command | good_command reports success as long as the last command in the chain succeeds, even if the first one silently produced nothing.

Step 2 — configuration and logging

orders_pipeline.sh — add configuration and a logging function
readonly LOG_DIR="/var/log/pipelines"
readonly LOG_FILE="${LOG_DIR}/orders_$(date +%Y%m%d).log"
readonly PIPELINE_SCRIPT="/data/pipelines/pipeline/orders_ingestion.py"

log() {
    local level="$1"; shift
    echo "$(date '+%Y-%m-%d %H:%M:%S') [${level}] $*" | tee -a "$LOG_FILE"
}
info()  { log "INFO"  "$@"; }
error() { log "ERROR" "$@"; }

tee -a is doing double duty here — it prints the message to the screen and appends it to the log file at the same time, so you get live feedback when running the script by hand and a permanent record when cron runs it unattended. The info/error wrapper functions exist purely so the rest of the script reads as info "message" instead of repeating the full log call everywhere.

Step 3 — a lock file, so cron can never run two copies at once

orders_pipeline.sh — prevent overlapping runs
readonly LOCK_FILE="/tmp/orders_pipeline.lock"

if [[ -f "$LOCK_FILE" ]]; then
    pid=$(cat "$LOCK_FILE")
    if kill -0 "$pid" 2>/dev/null; then
        error "Another instance is already running (PID $pid). Exiting."
        exit 1
    fi
    echo "Stale lock file found — removing"
fi
echo $$ > "$LOCK_FILE"

A pipeline that normally takes 30 minutes but occasionally runs long (from the earlier scenario: 2 hours) is a real risk if cron fires the next scheduled run before the first one finishes — now there are two copies writing to the same output. This lock file, checked with the same kill -0 trick from Part 07, makes that structurally impossible: a second invocation sees the lock, confirms the original process is genuinely still alive, and exits immediately instead of racing it.

Step 4 — cleanup that always runs, success or failure

orders_pipeline.sh — a trap that fires no matter how the script exits
cleanup() {
    local exit_code=$?
    rm -f "$LOCK_FILE"
    if [[ $exit_code -ne 0 ]]; then
        error "Script exited with code $exit_code"
    fi
}
trap cleanup EXIT

trap cleanup EXIT registers cleanup to run automatically no matter how the script ends — a normal finish, an exit 1 from an earlier check, or a crash from set -e catching a failed command. This is what guarantees the lock file from Step 3 is always removed — without it, one failed run would permanently lock out every future run.

Step 5 — preconditions, then the actual pipeline

orders_pipeline.sh — check before running, then run
main() {
    info "==== Starting orders pipeline ===="

    : "${DATABASE_URL:?DATABASE_URL is required}"
    [[ -f "$PIPELINE_SCRIPT" ]] || { error "Script not found: $PIPELINE_SCRIPT"; exit 1; }
    check_disk_space /data 10 || { error "Insufficient disk space"; exit 1; }

    local run_date="${1:-$(date -d 'yesterday' +%Y-%m-%d)}"
    info "Processing date: $run_date"

    python3 "$PIPELINE_SCRIPT" --date "$run_date" 2>&1 | tee -a "$LOG_FILE"

    info "==== Finished orders pipeline ===="
}

main "$@"

: "${DATABASE_URL:?DATABASE_URL is required}" is a bash idiom worth learning once: : is a no-op that does nothing with its argument, and ${VAR:?message} makes bash exit with that exact message if the variable is unset — a one-line, readable precondition check. Notice this step also calls check_disk_space, the exact function written in Part 10 — the whole script is built from pieces this module already taught, not new syntax appearing out of nowhere.

🎯 Pro Tip
This five-step build is the actual shape of almost every production pipeline wrapper you will encounter in a real job: strict mode, logging, a lock file, a cleanup trap, then preconditions before the real work. Once you've built one by hand like this, recognising (and extending) one you find in an existing codebase becomes far easier.
// Part 14 — Real World
💼 What This Looks Like at Work

Diagnosing a Failed Pipeline at 7 AM Using Only Linux Commands

Scenario — On-call DE at FreshCart · Pipeline alert fired at 06:47 AM

You receive a PagerDuty alert at 6:47 AM: "orders_pipeline has not completed by 06:45 AM SLA." You SSH into pipeline-01 — the exact server from Part 02. Every command below is one this module already taught you.

Step 1 — is it even still running?

Command
ps aux | grep orders_pipeline
output
pipeline 18734 98.2  4.1  python3 orders_pipeline.py --date 2026-03-17

It's running, at 98% CPU — worth a closer look, not yet a conclusion.

Step 2 — how long has it actually been running?

Command
ps -p 18734 -o pid,etime,pcpu,pmem,cmd
output
PID    ELAPSED  %CPU  %MEM  CMD
18734  02:14:32  98.2   4.1  python3 orders_pipeline.py

Two hours fourteen minutes, for a job that normally finishes in thirty. This is now a real problem.

Step 3 — check the disk, the single most common silent killer

Command
df -h /data
output
Filesystem  Size  Used  Avail  Use%  Mounted on
/dev/sdb1   500G  499G   512M   99%  /data

Disk is full. 512 MB free. This alone explains a hung process — writes block indefinitely once a filesystem has no space left.

Step 4 — find what's actually consuming the space

Command
du -sh /data/raw/2026/03/* | sort -rh
output
288G  /data/raw/2026/03/17
 24G  /data/raw/2026/03/16

Today's partition is 288 GB — roughly twelve times a normal day. Something is writing far more than it should.

Step 5 — find the exact file

Command
ls -lth /data/raw/2026/03/17/ | head -5
output
-rw-r--r-- 1 pipeline pipeline 288G Mar 17 06:28 orders_debug_dump.csv

A 288 GB debug dump file. Confirm it in the log:

Command
grep -i debug orders_20260317.log
output
2026-03-17 04:32:14 WARNING DEBUG_MODE=true detected — writing full row dump

That's the exact same warning line spotted in the log excerpt back in Part 02 — it wasn't a red herring, it was the root cause, sitting there for two hours before anyone looked closely.

Steps 6–8 — kill it cleanly, free the disk, fix the config, restart

Command
kill 18734                              # SIGTERM first
sleep 5
kill -0 18734 2>/dev/null && kill -9 18734

rm /data/raw/2026/03/17/orders_debug_dump.csv
sed -i 's/DEBUG_MODE=true/DEBUG_MODE=false/' /etc/pipelines/orders.env

nohup python3 /data/pipelines/pipeline/orders_ingestion.py --date 2026-03-17 \
    >> orders_20260317.log 2>&1 &
echo "Restarted with PID $!"

Step 9 — watch it actually recover

Command
tail -f orders_20260317.log | grep -E "INFO|ERROR"
output
07:03:41 INFO Batch 1 complete: 10000 rows
07:04:28 INFO Batch 2 complete: 10000 rows

Total time from alert to resolution: 22 minutes, and every command used was already covered in this module. A data engineer who knows these tools reaches root cause in minutes. One who does not might spend hours opening tickets and waiting for escalations instead.

// Part 15 — Misconceptions

Five Misconceptions About Linux and Shell Scripting

✕ ""A cron job that runs perfectly when I test the script by hand will run the same way under cron""
Cron runs scripts in a minimal environment with a stripped-down PATH and no .bashrc loaded — a script relying on either will fail under cron even though it worked flawlessly moments earlier by hand. Always test with absolute paths and explicit environment loading, covered fully in Part 12.
✕ ""rm -rf is fine as long as I'm careful about the path""
There is no undo. A single extra space (rm -rf /data /tmp instead of rm -rf /data/tmp) deletes two unrelated trees instead of one nested path — and the command completes before you notice. Before running any -rf, run the equivalent ls on the same path first and read what it lists.
✕ ""kill -9 is the normal way to stop a process""
kill (SIGTERM) should always be tried first — it gives a well-written process the chance to finish its current unit of work and close files cleanly. kill -9 (SIGKILL) skips all of that; a parquet file being written when SIGKILL lands is left truncated and unreadable. Reserve -9 for a process that ignored SIGTERM.
✕ ""set -e makes a bash script fail on any error, no exceptions""
set -e has real, documented gaps — it does NOT trigger inside an if condition, before || or &&, or inside most subshell contexts. A command that fails inside if some_command; then ... will NOT stop the script even with set -e active. Critical commands still need explicit error checking regardless.
✕ ""grep, awk, and sed are old tools that a modern data engineer doesn't really need""
They remain the fastest way to answer a quick question about a log file or a text-based data file — often faster than opening Python, importing pandas, and writing five lines to do what one piped command line already does. They are not a replacement for real data processing at scale, but for investigation and quick checks they are still the first tool reached for.
// Part 16 — Interview Prep

5 Interview Questions — With Complete Answers

Q1. A pipeline process on a Linux server appears to be running but is making no progress. How do you investigate?
I would work through a systematic sequence of checks, starting with the simplest possibilities. First, verify the process is actually running and check its resource usage: ps aux | grep pipeline_name or watch it in top. Look at CPU percentage (near 0% might mean it is waiting for I/O or a lock; near 100% might mean it is in a compute loop), memory usage (high and growing might mean a memory leak or unexpectedly large data), and the process state in the STAT column (D means waiting for disk I/O, S means sleeping, R means actively running). Second, check disk space: df -h. A full disk is the single most common cause of pipelines that appear running but are actually stuck — write operations block indefinitely when the filesystem is full. Find the space consumer with du -sh /data/* | sort -rh. Third, check the process's file handles to see what it is waiting on: lsof -p PID. If it has a file open with no recent modification time, it may be waiting for a read that never completes. Fourth, look at the pipeline's own log file: tail -f /var/log/pipelines/pipeline.log. A well-instrumented pipeline logs its progress — if the last entry was 90 minutes ago for a batch that normally logs every 5 minutes, that tells you exactly where it stalled. Fifth, if the pipeline connects to a database, check for lock waits on the database side using pg_stat_activity — the process may be blocked waiting for a database lock held by another transaction.
Q2. What does set -euo pipefail do in a bash script and why should every production script use it?
These three settings change bash's default error handling from "permissive and silent" to "strict and loud" — the correct behaviour for production pipeline scripts. -e (errexit) causes the script to exit immediately when any command returns a non-zero exit code. Without this, bash continues executing the next line even after a failure — a database migration step that fails would, without -e, still run every step after it while appearing to succeed. -u (nounset) causes the script to exit with an error when it tries to use an undefined variable, instead of silently substituting an empty string. A typo like $DATABSE_URL instead of $DATABASE_URL would otherwise pass an empty string into a connection string, producing a confusing error far from the actual typo. -o pipefail changes how pipes report failure. By default, command1 | command2 succeeds if the last command succeeds, even if command1 failed silently and produced no output. With pipefail, the whole pipe fails if any stage of it fails. Together, set -euo pipefail — the second line of every production bash script, right after the shebang — ensures a script stops at the first sign of trouble with a clear error instead of continuing through failures silently.
Q3. How do you find the top 10 largest files on a server, and delete files older than 7 days from a temp directory?
For the top 10 largest files: find /data -type f | xargs du -sh | sort -rh | head -10. -type f restricts to regular files, sort -rh sorts by human-readable size in reverse, head -10 limits the result. For a faster directory-level summary instead of individual files: du -sh /data/* | sort -rh | head -10. To delete files older than 7 days from /data/tmp: find /data/tmp -type f -mtime +7 -delete. mtime +7 means "modified more than 7 days ago" — the plus sign matters, since a minus sign there would mean the opposite. I would always run the same find without -delete first to see exactly what would be removed before committing to it, and for a production cleanup job I'd use -exec rm -v {} \; instead of -delete so each deletion is logged for an audit trail.
Q4. A cron job that works fine when run manually fails when run by cron. What are the most common causes?
This is one of the most common Linux debugging problems and almost always has one of four causes. The most common is PATH. An interactive shell has a rich PATH from .bashrc — /usr/local/bin, ~/bin, and more. Cron runs with a minimal default PATH, often just /usr/bin:/bin. A script calling python3 installed in /usr/local/bin fails under cron with "command not found" even though it works perfectly by hand. Fix: use absolute paths in cron entries and inside the script. The second is missing environment variables — anything set in .bashrc (API keys, DATABASE_URL) is not available to cron's minimal environment. Fix: source a dedicated environment file explicitly at the start of the script. The third is working directory — cron starts scripts in $HOME, not the script's own folder, so relative paths resolve incorrectly. Fix: cd "$(dirname "$0")" at the top of the script, or use absolute paths throughout. The fourth is output with nowhere to go — cron tries to email stdout/stderr, but most servers have no mail configured, so it's silently dropped. Fix: redirect explicitly, command >> /var/log/job.log 2>&1.
Q5. How would you use Linux commands to quickly check the quality of a newly arrived CSV before running a pipeline against it?
A quick check using only Linux tools covers structure, size, encoding, and value distributions — about two minutes of work that catches most ingestion-breaking issues. First, confirm the file exists and isn't empty: ls -lah shows size and modification time immediately. Second, check every row has the same column count: awk -F',' '{print NF}' orders.csv | sort | uniq -c should return exactly one distinct count. Multiple values mean malformed rows. Third, look at the header and a few rows: head -5 orders.csv confirms the header matches expectations and the data looks reasonable. Fourth, check encoding: file orders.csv reports ASCII, UTF-8, or ISO-8859 — the last one usually needs conversion before downstream processing. Fifth, spot-check a numeric column for bad values: awk -F',' 'NR>1 {print $3}' orders.csv | grep -vE '^[0-9.]+$' | head -20 finds anything non-numeric in what should be a numeric column. For a categorical column, cut -d',' -f4 orders.csv | sort | uniq -c immediately surfaces any unexpected value sitting alongside the expected ones.
// Common Mistakes

Mistakes Beginners Make Constantly

Running rm -rf without checking the path with ls first
There is no undo, no trash can, no confirmation prompt by default. Before any -rf, run the same path through ls or find to see exactly what it matches — a single stray space between two paths turns one deletion into two.
Testing a script by hand, seeing it work, and assuming it will work identically under cron
It won't, if it relies on anything from .bashrc — a rich PATH, exported environment variables, shell aliases. Cron's environment is deliberately minimal. Test cron entries by running them with the same minimal environment: env -i /bin/bash -c 'your command here'.
Reaching for kill -9 as the default, instead of plain kill first
SIGKILL gives a process no chance to close files or database connections cleanly. Always send SIGTERM (plain kill) first, wait a few seconds, and only escalate to -9 if the process is still alive.
Forgetting that uniq only removes adjacent duplicates
uniq file.txt on an unsorted file will not deduplicate it correctly — most duplicate lines will not be adjacent. Always sort first: sort file.txt | uniq, which is exactly why the two are used together as one idiom almost every time.
Writing a cron entry with a relative path or no output redirection
Both of these produce a job that fails silently — no error visible anywhere, because cron either can't find the relative path or has nowhere to send the output it produced. Always use absolute paths and redirect: script.sh >> /var/log/job.log 2>&1.
// Error Library

Errors You Will Hit — And Exactly Why They Happen

bash: /data/pipelines/run.sh: Permission denied
Cause: The script file does not have the execute permission bit set. A shell script is a text file — Linux requires explicit execute permission to run it as a program. Files created by downloading with curl/wget, or checked out fresh from git, often lack it.
Fix: chmod +x /data/pipelines/run.sh. Verify with ls -lah — you should see an x in the permission string (-rwxr-xr-x).
Cron job "ran" but nothing happened — no output, no error anywhere
Cause: Cron executed the job, the job failed, but its output was never captured. Cron tries to email stdout/stderr, but most servers have no mail configured, so the output is silently dropped.
Fix: Add explicit redirection to the crontab entry: >> /var/log/myjob.log 2>&1. Add set -euo pipefail to the script so failures are loud, and review the log after the next scheduled run.
xargs: argument line too long
Cause: The command line xargs is constructing exceeds the OS's ARG_MAX limit — this happens when piping thousands of filenames into a single xargs invocation.
Fix: Batch it: find /data -name "*.csv" | xargs -n 100 wc -l processes 100 files per invocation. Or use find ... -exec wc -l {} + directly, which batches automatically.
Script keeps running after a failed command, even with set -e active
Cause: set -e has real, documented exceptions: it does not trigger for a command inside an if condition, a command followed by || or &&, or most commands inside a subshell.
Fix: For anything inside an if, check the exit code explicitly: result=$(some_command) || { echo "failed"; exit 1; }. Do not rely on set -e alone for the most critical steps in a script.
find: WARNING: Hard link count is wrong for /data/nfs
Cause: Certain NFS filesystem implementations do not maintain accurate hard link counts, which find normally uses to optimise traversal — a known limitation, not a sign of corruption.
Fix: Add -noleaf: find -noleaf /data/nfs -name "*.parquet". Slower, but eliminates the warning and works correctly on NFS.

🎯 Key Takeaways

  • du -sh /path/* | sort -rh and df -h are the first two commands to run whenever a pipeline behaves strangely — a nearly-full disk causes symptoms that look like almost anything else.
  • Permissions are three groups of rwx (owner, group, others). 755 for scripts, 644 for configs, 600 for secrets. Diagnose "Permission denied" with ls -lah plus id, not guessing.
  • cut | sort | uniq -c | sort -rn is the single highest-value pipe in this module — it turns any column into a ranked frequency count without writing any Python.
  • Always send SIGTERM (plain kill) before SIGKILL (kill -9). SIGKILL gives a process no chance to close files or connections cleanly — a parquet file mid-write is left corrupted.
  • Every production bash script starts with set -euo pipefail as its second line — but know its real gaps: it does not fire inside if conditions, before ||/&&, or in most subshells.
  • Cron runs in a minimal environment: no .bashrc, a stripped PATH, $HOME as the working directory. Use absolute paths, source environment files explicitly, and always redirect output.
  • A production pipeline wrapper script is five layers stacked in order: strict mode, logging, a lock file to prevent overlapping runs, a cleanup trap that always fires, then preconditions before the real work.
  • The diagnostic sequence for a stuck pipeline: ps aux (is it running), df -h (is disk full), du -sh (what’s consuming it), lsof -p PID (what is it waiting on), tail -f the log (where did it actually stop). These five checks solve most production pipeline incidents.

What comes next

Module 17 covers Git for data teams — branching strategies, managing large data files, pre-commit hooks, and the workflows that keep teams moving without stepping on each other.

Module 17 → Git and Version Control for Data Projects
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...