expr command in Linux with examples: a practical, modern guide

A few months ago I audited a set of shell scripts that were quietly miscomputing totals. The issue wasn’t a missing semicolon or a typo. It was a subtle misunderstanding of how expr treats strings, numbers, and exit codes. That experience reminded me why this older tool still matters: it lives on in scripts, CI jobs, and tiny utilities that need simple arithmetic or string work without a heavier dependency. If you maintain shell code—especially legacy Bash or POSIX sh—you will meet expr. I’ll walk you through what it does, how it really behaves, and how I use it today alongside modern patterns like $(...), [[ ... ]], and AI-assisted shell snippet generation. You’ll see complete, runnable examples, guidance for when to choose expr and when not to, plus the gotchas that cost real teams time. The goal is not nostalgia; it’s reliable behavior in production scripts.

What expr really does in a modern shell

expr is an external command that evaluates expressions and prints the result to standard output. That sounds simple, but it has two special traits you must remember:

1) It is not a shell builtin. That means every call launches a separate process.

2) It speaks a small expression language that mixes arithmetic, string, and regex-like matching.

In practice, I treat expr like a tiny calculator that also understands strings. It’s still available on virtually every Unix-like system, which makes it useful when you’re writing portable scripts, minimal containers, or recovery-mode tools where you don’t want to assume bashisms.

The basic shape is:

expr EXPRESSION

Because the shell parses tokens before expr sees them, you must pay attention to quoting and escaping. The classic example is multiplication: * is a glob in the shell, so you must escape it or quote it. The same applies to <, >, |, and &, which the shell might treat as redirections or pipeline operators. I always treat expr expressions as a sequence of space-separated tokens and quote or escape anything that is not a bare number or bare word.

A key modern pattern is to prefer $(...) for command substitution instead of backticks. It’s more readable, nests correctly, and fits better with today’s linting tools.

Arithmetic rules, escaping, and exit codes

expr handles integer arithmetic only—no floating point. When I need decimals, I switch to awk, bc, or Python. For integers, expr is fine, as long as you remember the spacing and escaping rules.

Here’s a minimal arithmetic block I use to teach new teammates:

#!/usr/bin/env bash

set -euo pipefail

sum=$(expr 12 + 8)

product=$(expr 12 \* 2)

quotient=$(expr 25 / 4)

remainder=$(expr 25 % 4)

printf "sum=%s\n" "$sum"

printf "product=%s\n" "$product"

printf "quotient=%s\n" "$quotient"

printf "remainder=%s\n" "$remainder"

A couple of points to watch:

  • expr requires spaces between tokens. expr 1+2 fails.
  • You must escape because the shell expands it. I prefer \ for clarity.
  • Division is integer division; 25 / 4 yields 6.

The exit status is easy to miss, and it matters in scripts. expr returns:

  • 0 if the result is non-zero or non-empty
  • 1 if the result is zero or empty
  • 2 if there was a syntax error or another problem

That means a numeric result of 0 is a failure in shell terms. I don’t think of that as “good or bad,” but you must not assume 0 means success. If you need the value, capture stdout. If you need to branch on the numeric value, test the result explicitly.

Here’s a pattern I like for clear branching:

#!/usr/bin/env bash

set -euo pipefail

value=$(expr 7 - 7) # prints 0

if [ "$value" -eq 0 ]; then

echo "Value is zero"

else

echo "Value is non-zero"

fi

If you tried if expr 7 - 7; then ..., the then branch would not run, because the exit code is 1 for zero. I avoid that trap by always capturing the output.

Performance-wise, spawning expr typically costs a small amount of time—often in the 10–25ms range on a quiet system, higher under load. In tight loops, those forks add up. That’s one reason I choose (( ... )) or awk for heavy numeric work.

String work: length, substring, and regex matches

The string features are where expr still shines in portable scripts. It can compute length, take substrings, and perform regex-like matches with the : operator. The output is a number or a string, depending on the operator.

Length

#!/usr/bin/env bash

set -euo pipefail

name="sauron"

length=$(expr length "$name")

printf "length=%s\n" "$length"

This prints 6. I always quote the variable; unquoted strings with spaces split into tokens, which makes expr read multiple arguments.

Substring

expr substr STRING POSITION LENGTH extracts a substring using 1-based indexing.

#!/usr/bin/env bash

set -euo pipefail

path="/var/log/nginx/error.log"

segment=$(expr substr "$path" 6 3)

printf "segment=%s\n" "$segment"

This prints log because position 6 is the l in /var/log....

Regex-like matching with :

expr STRING : REGEX returns the length of the match anchored at the start of the string. If the regex uses \(...\) it returns the matching substring instead of length.

#!/usr/bin/env bash

set -euo pipefail

input="order-2026-04-19"

match_len=$(expr "$input" : "order-[0-9][0-9][0-9][0-9]")

printf "matchlen=%s\n" "$matchlen"

Because the regex is anchored at the start, this returns 10 for order-2026.

Here’s the capture form:

#!/usr/bin/env bash

set -euo pipefail

input="build-4187-ok"

number=$(expr "$input" : "build-\([0-9][0-9][0-9][0-9]\)")

printf "number=%s\n" "$number"

This prints 4187. Notice the escaped parentheses. This syntax is old-school regex, but it’s still useful when you want a minimal dependency tool.

Substring vs regex capture

I prefer substr for fixed positions and : for pattern matching. If the format can vary, : is safer. If the format is fixed, substr is usually clearer and faster.

Comparisons and boolean expressions that drive branching

expr can compare numbers and strings, and it can combine expressions with | and & for OR and AND. Keep in mind that these are not the shell’s logical operators, they are part of expr’s expression language.

Numeric and string comparisons

#!/usr/bin/env bash

set -euo pipefail

x=10

y=20

is_equal=$(expr $x = $y)

is_less=$(expr $x \< $y)

isnotequal=$(expr $x \!= $y)

printf "equal=%s\n" "$is_equal"

printf "less=%s\n" "$is_less"

printf "notequal=%s\n" "$isnot_equal"

expr prints 1 for true and 0 for false. Remember to escape < and != in many shells. You can also use string comparisons:

#!/usr/bin/env bash

set -euo pipefail

name="arya"

expected="arya"

same=$(expr "$name" = "$expected")

printf "same=%s\n" "$same"

Boolean expressions

#!/usr/bin/env bash

set -euo pipefail

result_or=$(expr length "geekss" \ 10)

result_and=$(expr length "geekss" \ 10)

printf "or=%s\n" "$result_or"

printf "and=%s\n" "$result_and"

Because length "geekss" is 6, the left side is false. The right side 19 - 6 > 10 is true. So or yields 1, and and yields 0.

When I use these, I often choose expr in cases where I need the value printed, not just a shell truthy/falsey. If I’m branching in the shell, I prefer [[ ... ]] or test for readability and to avoid the exit-code pitfall with zero.

Real scripts: input validation, parsing, and safe defaults

Here are a few short scripts that show how I use expr in the real world.

1) Validating numeric input before math

#!/usr/bin/env bash

set -euo pipefail

read -r -p "Enter item count: " count

Require an integer using expr‘s regex match

is_int=$(expr "$count" : "[0-9][0-9]*$")

if [ "$is_int" -eq 0 ]; then

echo "Please enter a whole number" >&2

exit 1

fi

next=$(expr "$count" + 1)

printf "next=%s\n" "$next"

The regex match returns the length of the matching substring. If it’s zero, the input didn’t match. This is a neat way to validate without extra tools.

2) Parsing version strings from logs

#!/usr/bin/env bash

set -euo pipefail

line="service=vault version=1.14.2 status=ok"

version=$(expr "$line" : "service=vault version=\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]*\)")

if [ -z "$version" ]; then

echo "Version not found" >&2

exit 1

fi

printf "version=%s\n" "$version"

I like this pattern when I need a very small dependency footprint. In 2026, I still do this in busybox-based images and recovery-mode scripts.

3) A tiny arithmetic helper for CI artifacts

#!/usr/bin/env bash

set -euo pipefail

Example: compute shard index for a CI run

runid=${RUNID:-0}

shards=${SHARDS:-5}

expr supports % for modulo

shard=$(expr "$run_id" % "$shards")

printf "shard=%s\n" "$shard"

This is the kind of snippet I use in CI pipelines where I want to split tests across workers without reaching for Python. For heavy computation I’d switch to a more capable tool, but for a single modulo, expr does the job.

4) String slicing for filenames

#!/usr/bin/env bash

set -euo pipefail

file="backup-2026-02-18.tar.gz"

Extract the date piece "2026-02-18" from a fixed position

stamp=$(expr substr "$file" 8 10)

printf "stamp=%s\n" "$stamp"

This is a fixed-format example. If the format can change, I use the : match with capture to avoid silent errors.

When I choose expr vs $(( )) , [[ ]] , and tools like awk

In 2026, you have better options for many tasks. Here’s the practical rulebook I follow.

I choose expr when:

  • I need portable POSIX sh behavior across minimal environments.
  • I need string length or regex-like capture without extra tools.
  • The script is short and process creation cost is acceptable.

I avoid expr when:

  • I need floating point or larger math.
  • I’m in a loop with thousands of iterations.
  • I can safely assume Bash 4+ or a modern shell.

Here’s a quick table with my preferences:

Task

Traditional with expr

Modern preference

Why I choose it

Integer math

expr 3 + 4

$((3 + 4))

No extra process, clearer syntax

String length

expr length "$s"

${#s}

Builtin, fast, clear

Substring

expr substr "$s" 2 4

${s:1:4}

Builtin, zero-based

Regex capture

expr "$s" : "\(...\)"

awk or grep -E

More expressive regex

POSIX sh script

expr

expr

Maximum portabilityThat table isn’t about “better or worse.” It’s about assumptions. If your environment is locked to POSIX sh, expr keeps you safe. If your environment is Bash or Zsh, builtins are faster and simpler.

Modern tooling and AI-assisted workflows

I often draft a shell script in a modern editor with AI pair programming and then run shellcheck for a static check. The AI helps with common patterns, but I still read every expr line carefully because escaping is easy to get wrong. A typical workflow for me in 2026 looks like this:

1) Draft script with AI assistance.

2) Run shellcheck locally.

3) Add unit-like checks using small fixtures.

4) Execute in a minimal container (busybox or alpine) to confirm portability.

The key detail is that AI-generated shell code can accidentally drop backslashes, or replace expr with echo in ways that change exit codes. I treat any expr usage as a “high attention” line during review.

Mistakes I still see and how to avoid them

Here are the pitfalls that show up again and again. I’ve tripped over every single one at least once.

1) Missing spaces between tokens

Bad:

expr 1+2

Good:

expr 1 + 2
expr expects each token as a separate argument. Without spaces it treats 1+2 as a string, not an arithmetic expression.

2) Forgetting to escape *, <, >, |, &, !

Bad:

expr 7 * 3
expr 2 < 5

Good:

expr 7 \* 3
expr 2 \< 5

The shell sees these characters before expr does. Always escape or quote them.

3) Relying on exit status for numeric meaning

Bad:

if expr 7 - 7; then

echo "zero"

fi

This won’t run the branch. Capture the output and test it instead:

value=$(expr 7 - 7)

if [ "$value" -eq 0 ]; then

echo "zero"

fi

4) Unquoted variables with spaces

Bad:

name="Sam Altman"
expr length $name

Good:

expr length "$name"

Unquoted variables are split by the shell. That creates multiple arguments, which changes the meaning of the expression.

5) Using expr for floats

expr is integer only. If you need decimals, switch tools. Here’s a small example with awk:

#!/usr/bin/env bash

set -euo pipefail

result=$(awk ‘BEGIN { printf "%.2f", 10.5 / 4 }‘)

printf "result=%s\n" "$result"

6) Assuming regex is not anchored

expr STRING : REGEX is anchored at the start. If you expect a match in the middle, either add .* at the start or use a different tool.

Bad expectation:

expr "alpha-beta" : "beta"

Good pattern if you still want expr:

expr "alpha-beta" : ".*beta"

7) Ignoring locales and character classes

Character classes like [a-z] can behave differently under locales. When I need predictable results in scripts, I set LC_ALL=C.

#!/usr/bin/env bash

set -euo pipefail

export LC_ALL=C

value=$(expr "Zebra" : "[A-Z][a-z]*")

printf "value=%s\n" "$value"

My decision checklist: should you use expr here?

I keep a small mental checklist. If you want a concrete rule set, here’s mine:

  • Portability required? If yes, expr is valid. If no, prefer builtins.
  • Single operation only? If yes, expr is fine. If many operations, avoid extra processes.
  • Regex capture needed? expr can do it, but awk or grep -E are more expressive.
  • Script maintained by a team? Builtins are easier for most developers to read.

If I’m writing a script that must run in a stripped-down recovery environment, I choose expr without hesitation. If I’m building a complex pipeline, I prefer builtins or a scripting language where the intent is clearer and the error modes are nicer.

Practical next steps for your toolbox

If you already have shell scripts in production, I recommend you do a quick audit: search for expr and review each call for escaping, quoting, and exit-status assumptions. That tiny effort can save hours of debugging later. For new scripts, decide early whether you target POSIX sh or a modern shell. That decision shapes everything else.

Here’s how I’d move forward if you want to get comfortable with expr quickly:

1) Build a small scratch script that covers arithmetic, string length, substring, and regex capture. Run it with both valid and invalid inputs.

2) Add set -euo pipefail and see how expr exit codes behave when results are zero or empty.

3) Try the same tasks with $(( ... )) and ${#var} to see the readability differences.

Operator precedence and why parentheses rarely help

One of the least intuitive parts of expr is operator precedence. The rules vary slightly across implementations, and because expr is external, you can’t rely on shell parsing to save you. I simplify my life with two rules:

  • I keep expressions short and obvious.
  • I split complex logic into multiple expr calls and combine results in shell conditionals.

Here’s a pattern I use instead of a single dense expression:

#!/usr/bin/env bash

set -euo pipefail

items=12

limit=10

Step 1: compute the delta

excess=$(expr "$items" - "$limit")

Step 2: test in the shell

if [ "$excess" -gt 0 ]; then

echo "Over limit by $excess"

else

echo "Within limit"

fi

This costs one extra expr call, but it pays back in clarity. I’ve seen too many brittle scripts that tried to compress everything into one line and later failed due to small changes.

Safe quoting patterns that never surprise me

If there’s one habit that pays off with expr, it’s disciplined quoting. The shell will always parse first. That means you control two levels of interpretation: the shell’s, then expr’s.

Here are my personal rules:

  • Always quote variables in string operations.
  • Always escape *, <, >, |, &, !, and ( ) if they aren’t inside quotes.
  • When using regex-like patterns, put them in quotes and escape capture parentheses.

Two concrete examples where quoting saves you:

name="Ada Lovelace"

Without quotes, expr sees two tokens: Ada and Lovelace

length=$(expr length "$name")

# Regex capture with quotes and escaped parens

version=$(expr "$line" : "version=\([0-9][0-9]\.[0-9][0-9]\)" )

The second example is easy to get wrong; I keep it as a snippet in my scratchpad so I can copy it quickly and avoid small mistakes.

Edge cases you’ll eventually hit

These are the edge cases I’ve actually encountered in production scripts.

Empty strings and missing arguments

expr doesn’t love missing tokens. An empty variable can turn a valid expression into a syntax error.

#!/usr/bin/env bash

set -euo pipefail

value=""

This can become: expr + 3, which is a syntax error

Safer approach: guard empty values

if [ -z "$value" ]; then

value=0

fi

sum=$(expr "$value" + 3)

Leading zeros and numeric interpretation

Some expr implementations treat numbers with leading zeros as decimal, while others are more strict. I normalize input when I care:

#!/usr/bin/env bash

set -euo pipefail

raw="007"

Strip leading zeros safely

norm=$(expr "$raw" : "0\([0-9][0-9]\)")

[ -z "$norm" ] && norm=0

printf "raw=%s norm=%s\n" "$raw" "$norm"

Negative numbers and unary minus

Unary minus can be a pain because expr expects spaced tokens. I make it explicit:

#!/usr/bin/env bash

set -euo pipefail

value=5

neg=$(expr 0 - "$value")

printf "neg=%s\n" "$neg"

Expressions that start with a dash

If a string begins with -, expr may misread it as an option. I use -- to end options when available, or I prefix a safe token.

#!/usr/bin/env bash

set -euo pipefail

str="-weird"

Some expr implementations accept --

len=$(expr -- length "$str" 2>/dev/null || expr length "$str")

printf "len=%s\n" "$len"

I include the fallback because not all implementations recognize --.

expr in POSIX sh and in BusyBox

The reason expr survives is portability. POSIX sh doesn’t guarantee modern parameter expansions like ${var:offset:length}. BusyBox often provides a minimal shell where expr is still present. In these environments, expr is the path of least surprise.

Here’s a short POSIX sh-compatible snippet that uses only portable features:

#!/bin/sh

name="kestrel"

len=expr length "$name"

if [ "$len" -gt 5 ]; then

echo "long"

else

echo "short"

fi

This uses backticks instead of $(...) to keep it strictly POSIX, though I personally prefer $(...) whenever the shell supports it.

Building a reusable expr helper script

Sometimes I wrap expr in tiny helper functions so the calling code reads cleanly. The helper does the quoting and parsing once, and then I reuse it.

#!/usr/bin/env bash

set -euo pipefail

Return 1 if string matches regex anchored at start

exprmatchlen() {

expr "$1" : "$2"

}

Return captured group or empty

expr_capture() {

expr "$1" : "$2"

}

Example usage

line="user=alice id=3921"

id=$(expr_capture "$line" "user=[a-z] id=\([0-9][0-9]\)")

if [ -n "$id" ]; then

echo "id=$id"

fi

This isn’t a full library, just enough to keep the main script readable. If I need more than this, I switch to awk or Python.

Practical scenarios with deeper examples

These are a few real-world scenarios where expr does exactly what I need without extra dependencies.

1) Dynamic file rollover with integer math

Suppose you generate logs like app.log.1, app.log.2, and you want to compute the next suffix while staying in POSIX sh.

#!/usr/bin/env bash

set -euo pipefail

latest="app.log.7"

Extract trailing number

num=$(expr "$latest" : ".\.\([0-9][0-9]\)")

[ -z "$num" ] && num=0

next=$(expr "$num" + 1)

echo "next file: app.log.$next"

This example uses a capture group to pull the suffix. If the file name doesn’t match, it defaults to 0 and creates .1.

2) Validating structured IDs

If your system uses IDs like user-1234 and you want a fast validation step in a script that can’t depend on Python:

#!/usr/bin/env bash

set -euo pipefail

id="$1"

valid=$(expr "$id" : "user-[0-9][0-9][0-9][0-9]$")

if [ "$valid" -eq 0 ]; then

echo "Invalid id" >&2

exit 2

fi

echo "OK"

The validation is anchored and checks length and format in one shot.

3) Trim prefix and compute remainder length

This is a quick trick when you need to drop a known prefix and measure what’s left.

#!/usr/bin/env bash

set -euo pipefail

s="env=prod;region=us-east"

Compute prefix length including the delimiter

prefix_len=$(expr "$s" : "env=[a-z]*;")

Substring after the prefix

remain=$(expr substr "$s" $(expr "$prefix_len" + 1) 999)

printf "remain=%s\n" "$remain"

It’s not the most elegant, but it stays portable and uses only expr and shell builtins.

Performance considerations, realistically

I mentioned earlier that expr spawns a process. In a tight loop, that adds up. I don’t measure microseconds; I look for a visible slowdown. For example, a loop that runs a few dozen times won’t matter. A loop that runs tens of thousands of times will.

Here’s how I think about it:

  • Low iteration counts (under 100): use expr if it’s convenient.
  • Medium iteration counts (hundreds to low thousands): prefer shell arithmetic or awk if possible.
  • High iteration counts (tens of thousands+): avoid expr, use builtins or a different language.

If I have to stay POSIX and still do heavy processing, I usually switch to a single awk script that reads all input and handles the math inside one process.

Differences across implementations

Most of the time expr behaves the same across common Unix-like systems, but a few edge differences exist:

  • Some versions accept -- to end options; some don’t.
  • Error messages vary, so don’t parse them.
  • Regex flavor is basic and old-school; character classes and escapes may behave slightly differently under locales.

I avoid relying on implementation quirks. I focus on portable usage: explicit escaping, anchored matches, and clear token spacing.

expr vs test and [ in shell logic

It’s easy to confuse expr with test (aka [ ... ]). They solve different problems:

  • test is for boolean checks; it does not print results.
  • expr computes and prints results; its exit code follows the “zero is failure” rule for a zero/empty result.

If I’m writing logic, I often combine them: expr to compute, test to branch.

#!/usr/bin/env bash

set -euo pipefail

value=$(expr 9 - 3)

if [ "$value" -gt 4 ]; then

echo "greater than four"

fi

This reads cleanly and avoids the exit-code trap.

Using expr safely with user input

Any time user input is involved, I assume it can contain spaces or odd characters. That means I double down on quotes and I avoid passing user input where the shell might interpret it.

Here’s an example where I validate and normalize input before I compute:

#!/usr/bin/env bash

set -euo pipefail

read -r -p "Enter a positive integer: " n

Require digits only

valid=$(expr "$n" : "[0-9][0-9]*$")

if [ "$valid" -eq 0 ]; then

echo "Invalid number" >&2

exit 1

fi

Safe arithmetic

half=$(expr "$n" / 2)

printf "half=%s\n" "$half"

Because expr is external, I prefer to validate before I compute. It avoids cryptic errors and makes scripts more user-friendly.

Comparisons table: traditional vs modern patterns

Below is an expanded comparison table that includes portability and performance notes.

Task

expr approach

Modern approach

Portability

Performance

My take

Integer math

expr 5 + 7

$((5 + 7))

High

Low

Use modern when available

String length

expr length "$s"

${#s}

High

Low

Prefer builtin if Bash/Zsh

Substring

expr substr "$s" 2 4

${s:1:4}

High

Low

Builtin is clearer

Regex capture

expr "$s" : "\(...\)"

awk/grep -E

Medium

Medium

Use awk for complex patterns

Portable script

expr

expr

Highest

Low

Still the right tool

Float math

not supported

awk/bc/Python

Medium

Medium

Avoid exprThe best choice depends on how constrained your runtime is. If you control the environment, use modern builtins. If you don’t, expr is still a solid fallback.

Troubleshooting guide: when expr doesn’t behave

When expr fails, I follow a quick checklist. It catches most issues in minutes.

1) Check spacing. Each token must be separate.

2) Check escaping. Any * | & ! ( ) should be escaped or quoted.

3) Check quotes. Any variable that might contain spaces must be quoted.

4) Check empty values. Replace empty with a default if needed.

5) Print the raw args. Temporarily add printf ‘%s\n‘ "$var" to ensure what you think you’re passing is actually passed.

A small debug wrapper can also help:

#!/usr/bin/env bash

set -euo pipefail

expr_debug() {

echo "expr args: $*" >&2

expr "$@"

}

result=$(expr_debug 7 \* 6)

I remove this after debugging, but it’s a fast way to see shell parsing effects.

expr and CI environments

CI environments are a perfect place for expr because you often want tiny, deterministic scripts that don’t rely on extra packages. I use expr for simple branching and sharding, and I lean on its portability for minimal Docker images.

One practical pattern is calculating matrix indices in POSIX sh without Bash arithmetic:

#!/usr/bin/env bash

set -euo pipefail

index=${CINODEINDEX:-0}

count=${CINODETOTAL:-1}

if [ "$count" -le 0 ]; then

echo "Invalid shard count" >&2

exit 2

fi

shard=$(expr "$index" % "$count")

echo "shard=$shard"

This avoids the need for Bash-specific arithmetic or Python dependencies in minimal CI runners.

Advanced regex capture tips

expr regex is old-school, but you can still do a lot with it if you keep it simple.

  • Use character classes like [0-9] and [a-z] for portability.
  • Avoid fancy regex features like non-capturing groups—they won’t work.
  • Anchor your matches when you want certainty (^ is implicit at the start of the pattern in expr matching).

Here’s a pattern to capture a semantic version from a line with extra text:

#!/usr/bin/env bash

set -euo pipefail

line="release: v2.10.3 (stable)"

ver=$(expr "$line" : ".v\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\)")

printf "ver=%s\n" "$ver"

The .* at the start allows the match to happen anywhere in the string. Without it, expr would only match at the beginning.

Handling files and paths carefully

expr often shows up in file-path logic, so here’s a small example that normalizes and slices paths while staying portable:

#!/usr/bin/env bash

set -euo pipefail

path="/var/log/nginx/access.log"

Get the filename after the last slash using regex capture

file=$(expr "$path" : "./\(.\)")

Get the extension length

ext_len=$(expr "$file" : ".\.\([a-z][a-z]\)")

printf "file=%s ext=%s\n" "$file" "$ext_len"

This is not as expressive as parameter expansion in Bash, but it’s portable and gets the job done.

Working with exit codes the safe way

One of my strongest expr habits is separating computation from branching. When I need to check a computed value, I store it and test it explicitly. Here’s a full example that uses both numeric and string results safely:

#!/usr/bin/env bash

set -euo pipefail

input="ticket-0000"

Capture the numeric part

num=$(expr "$input" : "ticket-\([0-9][0-9]*\)")

if [ -z "$num" ]; then

echo "No number found" >&2

exit 1

fi

Convert to next ticket number

next=$(expr "$num" + 1)

Left-pad to 4 digits using shell formatting

printf "next=ticket-%04d\n" "$next"

Note how I avoid if expr ... for the match result. I always check the output in shell logic.

When NOT to use expr (and what I use instead)

There are cases where expr isn’t just inconvenient; it’s the wrong tool. These are the big ones:

  • Complex parsing: If I need to parse CSV or JSON, I use awk, jq, or Python.
  • Floating point: I use awk or bc.
  • Heavy loops: I use shell builtins or move the logic into a single awk program.
  • Multi-line text processing: I use awk, sed, or perl.

I don’t want to be dogmatic. If your environment supports modern Bash, use Bash. If it doesn’t, expr is still a reliable option—just be mindful of its limitations.

Production considerations: auditability and maintenance

The biggest problem with expr in production isn’t correctness; it’s readability. Developers unfamiliar with it often misread what’s happening. Here’s how I make expr scripts more maintainable:

  • Add one-line comments before non-obvious expr calls.
  • Keep expressions short and break them into steps.
  • Prefer printf for output formatting so the data is clear.
  • Add a small “sanity check” section at the top of the script.

Example with small comments:

#!/usr/bin/env bash

set -euo pipefail

Extract the numeric suffix from a known pattern

suffix=$(expr "$1" : ".-\([0-9][0-9]\)$")

[ -z "$suffix" ] && { echo "Invalid input" >&2; exit 1; }

Increment

next=$(expr "$suffix" + 1)

printf "next=%s\n" "$next"

I keep comments short and focused: just enough to prevent confusion.

A minimal portable toolkit using expr

When I’m in a recovery shell or in a minimal container, this is my go-to toolkit:

  • expr for arithmetic and simple regex capture
  • test/[ for branching
  • printf for output
  • awk for more complex math or parsing if it exists

This combination gives me just enough power without pulling in full scripting languages.

Quick reference: expr operators you’ll actually use

I don’t memorize the full operator list; I keep a small subset handy:

  • Arithmetic: + - * / %
  • Comparison: = != < >=
  • String length: length STRING
  • Substring: substr STRING POSITION LENGTH
  • Regex match: STRING : REGEX
  • Boolean: EXPR1 | EXPR2 and EXPR1 & EXPR2

As long as you remember to escape special characters, these cover most use cases.

A final checklist before you ship a script with expr

Here’s the short checklist I run through before I merge a script:

  • Are all expr tokens spaced correctly?
  • Are variables and regexes properly quoted?
  • Are special characters escaped?
  • Does the script handle zero/empty results explicitly?
  • Is performance acceptable for the expected usage?

If I can answer “yes” across the board, I’m comfortable shipping it.

Closing thoughts

expr is old, but it’s not obsolete. It’s a tiny, predictable tool that fits well in constrained environments and legacy scripts. The key is respect: respect its exit codes, respect the shell’s parsing rules, and respect the edge cases. When I do that, it’s reliable and clear.

If you’re building new scripts, you’ll probably use modern shell builtins most of the time. But when you need portability or a minimal dependency footprint, expr is still one of the best tools you can reach for. The examples and patterns above should give you enough practical depth to use it confidently—and to avoid the mistakes that lead to subtle, production-grade bugs.

If you want, I can also expand this further with a compact cheat sheet, additional POSIX sh examples, or a migration guide that replaces expr with modern builtins in existing scripts.

Scroll to Top